Complete client UI i18n migration
Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
@@ -10,6 +10,7 @@ import React, {
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -157,6 +158,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
function renderListEmptyComponent(input: {
|
||||
renderModel: AgentStreamRenderModel;
|
||||
emptyStateStyle: StyleProp<ViewStyle>;
|
||||
emptyText: string;
|
||||
}): ReactNode {
|
||||
if (
|
||||
input.renderModel.boundary.hasVirtualizedHistory ||
|
||||
@@ -170,7 +172,7 @@ function renderListEmptyComponent(input: {
|
||||
|
||||
return (
|
||||
<View style={input.emptyStateStyle}>
|
||||
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
|
||||
<Text style={stylesheet.emptyStateText}>{input.emptyText}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -245,6 +247,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const streamRenderStrategy = useMemo(
|
||||
@@ -637,8 +640,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
|
||||
const listEmptyComponent = useMemo(
|
||||
() => renderListEmptyComponent({ renderModel, emptyStateStyle }),
|
||||
[renderModel, emptyStateStyle],
|
||||
() =>
|
||||
renderListEmptyComponent({
|
||||
renderModel,
|
||||
emptyStateStyle,
|
||||
emptyText: t("agentStream.empty"),
|
||||
}),
|
||||
[renderModel, emptyStateStyle, t],
|
||||
);
|
||||
|
||||
const { boundary, auxiliary } = renderModel;
|
||||
@@ -745,7 +753,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Scroll to bottom"
|
||||
accessibilityLabel={t("agentStream.scrollToBottom")}
|
||||
testID="scroll-to-bottom-button"
|
||||
>
|
||||
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
|
||||
@@ -906,11 +914,14 @@ function PermissionRequestCard({
|
||||
permission: PendingPermission;
|
||||
client: DaemonClient | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const { request } = permission;
|
||||
const isPlanRequest = request.kind === "plan";
|
||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||
const title = isPlanRequest
|
||||
? t("agentStream.permission.plan")
|
||||
: (request.title ?? request.name ?? t("agentStream.permission.required"));
|
||||
const description = request.description ?? "";
|
||||
const resolvedToolCallDetail = useMemo(
|
||||
() =>
|
||||
@@ -931,19 +942,21 @@ function PermissionRequestCard({
|
||||
return [
|
||||
{
|
||||
id: "reject",
|
||||
label: "Deny",
|
||||
label: t("agentStream.permission.deny"),
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "accept",
|
||||
label: isPlanRequest ? "Implement" : "Accept",
|
||||
label: isPlanRequest
|
||||
? t("agentStream.permission.implement")
|
||||
: t("agentStream.permission.accept"),
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
},
|
||||
];
|
||||
}, [isPlanRequest, request]);
|
||||
}, [isPlanRequest, request, t]);
|
||||
|
||||
const planMarkdown = useMemo(() => {
|
||||
if (!request) {
|
||||
@@ -968,7 +981,7 @@ function PermissionRequestCard({
|
||||
response: AgentPermissionResponse;
|
||||
}) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
return client.respondToPermissionAndWait(
|
||||
input.agentId,
|
||||
@@ -1042,7 +1055,7 @@ function PermissionRequestCard({
|
||||
const footer = (
|
||||
<>
|
||||
<Text testID="permission-request-question" style={permissionStyles.question}>
|
||||
How would you like to proceed?
|
||||
{t("agentStream.permission.question")}
|
||||
</Text>
|
||||
|
||||
<View style={optionsContainerStyle}>
|
||||
@@ -1094,7 +1107,7 @@ function PermissionRequestCard({
|
||||
|
||||
{planMarkdown ? (
|
||||
<PlanCard
|
||||
title="Proposed plan"
|
||||
title={t("agentStream.permission.proposedPlan")}
|
||||
text={planMarkdown}
|
||||
testID="permission-plan-card"
|
||||
disableOuterSpacing
|
||||
|
||||
@@ -69,6 +69,7 @@ import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { I18nProvider } from "@/i18n/provider";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
@@ -934,13 +935,15 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
function RootProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
<I18nProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</I18nProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -120,6 +121,7 @@ function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
@@ -190,13 +192,13 @@ export default function PairScanScreen() {
|
||||
navigateToPairedHost(profile.serverId);
|
||||
} catch (error) {
|
||||
lastScannedRef.current = null;
|
||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||
Alert.alert("Error", message);
|
||||
const message = error instanceof Error ? error.message : t("pairing.scan.unableToPair");
|
||||
Alert.alert(t("pairing.scan.errorTitle"), message);
|
||||
} finally {
|
||||
setIsPairing(false);
|
||||
}
|
||||
},
|
||||
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
|
||||
[isPairing, navigateToPairedHost, t, upsertDaemonFromOfferUrl],
|
||||
);
|
||||
|
||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||
@@ -216,15 +218,13 @@ export default function PairScanScreen() {
|
||||
if (isWeb) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Scan QR" onBack={handleRouterBack} />
|
||||
<BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
|
||||
<View style={bodyStyle}>
|
||||
<View style={styles.permissionCard}>
|
||||
<Text style={styles.permissionTitle}>Not available on web</Text>
|
||||
<Text style={styles.permissionBody}>
|
||||
{`QR scanning isn't supported in the web build. Use "Paste link" instead.`}
|
||||
</Text>
|
||||
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
|
||||
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
|
||||
<Pressable style={styles.permissionButton} onPress={closeToSource}>
|
||||
<Text style={styles.permissionButtonText}>Back to Settings</Text>
|
||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -236,17 +236,15 @@ export default function PairScanScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Scan QR" onBack={closeToSource} />
|
||||
<BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
|
||||
|
||||
<View style={bodyStyle}>
|
||||
{!granted ? (
|
||||
<View style={styles.permissionCard}>
|
||||
<Text style={styles.permissionTitle}>Camera permission</Text>
|
||||
<Text style={styles.permissionBody}>
|
||||
Allow camera access to scan the pairing QR code from your daemon.
|
||||
</Text>
|
||||
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
|
||||
<Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</Text>
|
||||
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
|
||||
<Text style={styles.permissionButtonText}>Grant permission</Text>
|
||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
@@ -264,7 +262,7 @@ export default function PairScanScreen() {
|
||||
<View style={CORNER_BL_STYLE} />
|
||||
<View style={CORNER_BR_STYLE} />
|
||||
</View>
|
||||
{isPairing ? <Text style={helperTextStyle}>Pairing…</Text> : null}
|
||||
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AssistantFileLinkClassification,
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export interface AssistantFileLinkSource {
|
||||
href: string;
|
||||
@@ -60,7 +61,7 @@ export interface FetchDaemonResolutionInput {
|
||||
|
||||
export class UnresolvedFileLinkError extends Error {
|
||||
constructor(readonly token: string) {
|
||||
super(`No file found for ${token}`);
|
||||
super(i18n.t("common.errors.noFileFound", { token }));
|
||||
this.name = "UnresolvedFileLinkError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
UnresolvedFileLinkError,
|
||||
type AssistantFileLinkResolution,
|
||||
type AssistantFileLinkSource,
|
||||
} from "./resolver";
|
||||
@@ -40,6 +40,7 @@ type AssistantFileLinkQueryKey = readonly [
|
||||
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
|
||||
|
||||
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
|
||||
const { t } = useTranslation();
|
||||
const context = useAssistantFileLinkResolverContext();
|
||||
const queryClient = useQueryClient();
|
||||
const stableSource = useStableSource(source);
|
||||
@@ -91,6 +92,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
||||
disposition,
|
||||
context,
|
||||
queryClient,
|
||||
formatNoFileFoundMessage: (token) => t("common.errors.noFileFound", { token }),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -163,6 +165,7 @@ function openAssistantFileLink(input: {
|
||||
disposition: OpenFileDisposition;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
queryClient: ReturnType<typeof useQueryClient>;
|
||||
formatNoFileFoundMessage: (token: string) => string;
|
||||
}): void {
|
||||
const capturedConfig = input.context.configRef.current;
|
||||
const capturedResolution = classifyForResolution(input.source, {
|
||||
@@ -211,7 +214,7 @@ function openAssistantFileLink(input: {
|
||||
} catch (error) {
|
||||
await dispatchUnresolvedError({
|
||||
error,
|
||||
fallbackToken: capturedResolution.token,
|
||||
noFileFoundMessage: input.formatNoFileFoundMessage(capturedResolution.token),
|
||||
capturedServerId: capturedConfig.serverId,
|
||||
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
|
||||
context: input.context,
|
||||
@@ -322,7 +325,7 @@ async function dispatchExternalUrl(input: {
|
||||
|
||||
async function dispatchUnresolvedError(input: {
|
||||
error: unknown;
|
||||
fallbackToken: string;
|
||||
noFileFoundMessage: string;
|
||||
capturedServerId?: string;
|
||||
capturedWorkspaceRoot?: string;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
@@ -334,9 +337,7 @@ async function dispatchUnresolvedError(input: {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const token =
|
||||
input.error instanceof UnresolvedFileLinkError ? input.error.token : input.fallbackToken;
|
||||
current.toast?.show(`No file found for ${token}`, {
|
||||
current.toast?.show(input.noFileFoundMessage, {
|
||||
variant: "error",
|
||||
testID: "assistant-file-link-not-found-toast",
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ClientSlashCommand {
|
||||
name: string;
|
||||
aliases: readonly string[];
|
||||
description: string;
|
||||
descriptionKey: "composer.clientCommands.archiveAgent" | "composer.clientCommands.freshDraft";
|
||||
argumentHint: string;
|
||||
kind: ClientSlashCommandKind;
|
||||
execution: ClientSlashCommandExecution;
|
||||
@@ -18,6 +19,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
|
||||
name: "exit",
|
||||
aliases: ["quit", "q"],
|
||||
description: "Archive the current agent",
|
||||
descriptionKey: "composer.clientCommands.archiveAgent",
|
||||
argumentHint: "",
|
||||
kind: "archive-agent",
|
||||
execution: "immediate",
|
||||
@@ -26,6 +28,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
|
||||
name: "clear",
|
||||
aliases: ["new"],
|
||||
description: "Archive this agent and start a fresh draft",
|
||||
descriptionKey: "composer.clientCommands.freshDraft",
|
||||
argumentHint: "",
|
||||
kind: "replace-agent-with-draft",
|
||||
execution: "immediate",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo } from "react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import type { TextInputProps } from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
@@ -304,6 +305,7 @@ export function SheetHeaderView({
|
||||
testID?: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
@@ -327,7 +329,7 @@ export function SheetHeaderView({
|
||||
hitSlop={8}
|
||||
style={styles.headerBackButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
|
||||
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")}
|
||||
testID="sheet-header-back"
|
||||
>
|
||||
{({ pressed }) => (
|
||||
@@ -347,7 +349,11 @@ export function SheetHeaderView({
|
||||
</View>
|
||||
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
|
||||
{showCloseButton ? (
|
||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||
<Pressable
|
||||
accessibilityLabel={t("common.actions.close")}
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
{({ pressed }) => (
|
||||
<X
|
||||
size={16}
|
||||
@@ -363,7 +369,7 @@ export function SheetHeaderView({
|
||||
<AdaptiveTextInput
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={SEARCH_INPUT_STYLE}
|
||||
placeholder={search.placeholder ?? "Search"}
|
||||
placeholder={search.placeholder ?? t("common.actions.search")}
|
||||
resetKey={search.resetKey}
|
||||
onChangeText={handleSearchChange}
|
||||
autoCapitalize="none"
|
||||
@@ -379,6 +385,7 @@ export function SheetHeaderView({
|
||||
|
||||
export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const back = header.back;
|
||||
const handleBackPress = back?.onPress;
|
||||
const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions);
|
||||
@@ -393,7 +400,9 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
hitSlop={8}
|
||||
style={styles.headerBackButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
|
||||
accessibilityLabel={
|
||||
back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")
|
||||
}
|
||||
testID="sheet-header-back"
|
||||
>
|
||||
{({ pressed }) => (
|
||||
@@ -417,7 +426,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
<AdaptiveTextInput
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={SEARCH_INPUT_STYLE}
|
||||
placeholder={header.search.placeholder ?? "Search"}
|
||||
placeholder={header.search.placeholder ?? t("common.actions.search")}
|
||||
resetKey={header.search.resetKey}
|
||||
onChangeText={header.search.onChange}
|
||||
autoCapitalize="none"
|
||||
@@ -462,6 +471,7 @@ export function AdaptiveModalSheet({
|
||||
presentation,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
@@ -585,7 +595,11 @@ export function AdaptiveModalSheet({
|
||||
|
||||
const desktopContent = (
|
||||
<View style={styles.desktopOverlay} testID={testID}>
|
||||
<Pressable accessibilityLabel="Dismiss" style={ABSOLUTE_FILL_STYLE} onPress={onClose} />
|
||||
<Pressable
|
||||
accessibilityLabel={t("common.actions.dismiss")}
|
||||
style={ABSOLUTE_FILL_STYLE}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View style={desktopCardStyle}>
|
||||
{onFilesDropped ? (
|
||||
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
const ADD_CONNECTION_HEADER: SheetHeader = { title: "Add connection" };
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
option: {
|
||||
flexDirection: "row",
|
||||
@@ -49,6 +48,8 @@ export function AddHostMethodModal({
|
||||
onPasteLink,
|
||||
}: AddHostMethodModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.connectionMethods.title") }), [t]);
|
||||
|
||||
const handleDirect = useCallback(() => {
|
||||
onDirectConnection();
|
||||
@@ -64,7 +65,7 @@ export function AddHostMethodModal({
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={ADD_CONNECTION_HEADER}
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
testID="add-host-method-modal"
|
||||
@@ -73,13 +74,15 @@ export function AddHostMethodModal({
|
||||
style={styles.option}
|
||||
onPress={handleDirect}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Direct connection"
|
||||
accessibilityLabel={t("pairing.connectionMethods.direct.title")}
|
||||
testID="add-host-method-direct"
|
||||
>
|
||||
<Link2 size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Direct connection</Text>
|
||||
<Text style={styles.optionSubtext}>Local network or VPN.</Text>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.direct.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
{t("pairing.connectionMethods.direct.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
@@ -88,12 +91,14 @@ export function AddHostMethodModal({
|
||||
style={styles.option}
|
||||
onPress={handleScan}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Scan QR code"
|
||||
accessibilityLabel={t("pairing.connectionMethods.scanQr.title")}
|
||||
>
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Scan QR code</Text>
|
||||
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.scanQr.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
{t("pairing.connectionMethods.scanQr.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
@@ -102,13 +107,15 @@ export function AddHostMethodModal({
|
||||
style={styles.option}
|
||||
onPress={handlePaste}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Paste pairing link"
|
||||
accessibilityLabel={t("pairing.connectionMethods.pasteLink.title")}
|
||||
testID="add-host-method-pair-link"
|
||||
>
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Paste pairing link</Text>
|
||||
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.pasteLink.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
{t("pairing.connectionMethods.pasteLink.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export interface QueuedComposerMessage {
|
||||
id: string;
|
||||
@@ -244,6 +245,7 @@ export interface SendQueuedComposerMessageNowInput {
|
||||
messageId: string;
|
||||
queue: QueueWriter;
|
||||
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
|
||||
failedToSendMessage?: string;
|
||||
}
|
||||
|
||||
export type SendQueuedComposerMessageNowResult =
|
||||
@@ -275,7 +277,10 @@ export async function sendQueuedComposerMessageNow(
|
||||
});
|
||||
return {
|
||||
status: "failed",
|
||||
errorMessage: error instanceof Error ? error.message : "Failed to send message",
|
||||
errorMessage:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: (input.failedToSendMessage ?? i18n.t("composer.errors.failedToSend")),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -56,7 +57,7 @@ import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manife
|
||||
import {
|
||||
getFeatureHighlightColor,
|
||||
getFeatureTooltip,
|
||||
getAgentControlHint,
|
||||
getAgentControlHintKey,
|
||||
formatThinkingOptionLabel,
|
||||
resolveAgentModelSelection,
|
||||
} from "@/composer/agent-controls/utils";
|
||||
@@ -415,6 +416,7 @@ function ControlledAgentControls({
|
||||
isCompactLayout,
|
||||
}: ControlledAgentControlsProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
|
||||
@@ -432,7 +434,11 @@ function ControlledAgentControls({
|
||||
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0,
|
||||
);
|
||||
|
||||
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
|
||||
const displayProvider = findOptionLabel(
|
||||
providerOptions,
|
||||
selectedProviderId,
|
||||
t("agentControls.provider.fallback"),
|
||||
);
|
||||
const formattedThinkingOptions = useMemo(
|
||||
() => toThinkingControlOptions(thinkingOptions),
|
||||
[thinkingOptions],
|
||||
@@ -440,7 +446,7 @@ function ControlledAgentControls({
|
||||
const displayThinking = findOptionLabel(
|
||||
formattedThinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
formattedThinkingOptions[0]?.label ?? "Unknown",
|
||||
formattedThinkingOptions[0]?.label ?? t("agentControls.thinking.unknown"),
|
||||
);
|
||||
|
||||
const ProviderIcon = resolveProviderIcon(provider);
|
||||
@@ -709,6 +715,7 @@ const DESKTOP_SEARCH_THRESHOLD = 6;
|
||||
|
||||
function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
provider,
|
||||
providerOptions,
|
||||
@@ -764,7 +771,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
onPress={handleProviderPress}
|
||||
style={providerPressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent provider"
|
||||
accessibilityLabel={t("agentControls.provider.select")}
|
||||
testID="agent-provider-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayProvider}</Text>
|
||||
@@ -805,7 +812,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getAgentControlHint("model")}</Text>
|
||||
<Text style={styles.tooltipText}>{t(getAgentControlHintKey("model"))}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@@ -821,7 +828,9 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
onPress={handleThinkingPress}
|
||||
style={thinkingPressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select thinking option (${displayThinking})`}
|
||||
accessibilityLabel={t("agentControls.thinking.selectWithValue", {
|
||||
value: displayThinking,
|
||||
})}
|
||||
testID="agent-thinking-selector"
|
||||
>
|
||||
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
@@ -830,7 +839,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getAgentControlHint("thinking")}</Text>
|
||||
<Text style={styles.tooltipText}>{t(getAgentControlHintKey("thinking"))}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
@@ -901,6 +910,7 @@ interface SheetAgentControlsContentProps {
|
||||
|
||||
function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
provider,
|
||||
selectedModelId,
|
||||
@@ -936,6 +946,10 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
|
||||
const hasThinking = comboboxThinkingOptions.length > 0;
|
||||
const hasFeatures = Boolean(features && features.length > 0);
|
||||
const featuresSheetHeader = useMemo<SheetHeader>(
|
||||
() => ({ title: t("agentControls.features.title") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]);
|
||||
const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]);
|
||||
@@ -1012,7 +1026,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
disabled={disabled || !canSelectThinking}
|
||||
style={thinkingButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select thinking option"
|
||||
accessibilityLabel={t("agentControls.thinking.select")}
|
||||
testID="agent-controls-thinking"
|
||||
>
|
||||
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
@@ -1025,7 +1039,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
disabled={disabled}
|
||||
style={featuresButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open agent features"
|
||||
accessibilityLabel={t("agentControls.features.open")}
|
||||
testID="agent-controls-features"
|
||||
>
|
||||
<Settings2 size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
@@ -1038,7 +1052,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
value={selectedThinkingOptionId ?? ""}
|
||||
onSelect={handleSelectThinkingAndClose}
|
||||
searchable={false}
|
||||
title="Thinking"
|
||||
title={t("agentControls.thinking.title")}
|
||||
open={activeSheet === "thinking"}
|
||||
onOpenChange={handleThinkingSheetOpenChange}
|
||||
anchorRef={thinkingAnchorRef}
|
||||
@@ -1047,7 +1061,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
) : null}
|
||||
|
||||
<AdaptiveModalSheet
|
||||
header={FEATURES_SHEET_HEADER}
|
||||
header={featuresSheetHeader}
|
||||
visible={activeSheet === "features"}
|
||||
onClose={handleCloseSheet}
|
||||
testID="agent-features-sheet"
|
||||
@@ -1206,6 +1220,7 @@ function SheetFeatureItem({
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const featureSelector: AgentControlSelector = `feature-${feature.id}`;
|
||||
|
||||
const handleFeatureOpenChange = useMemo(
|
||||
@@ -1257,7 +1272,9 @@ function SheetFeatureItem({
|
||||
)}
|
||||
/>
|
||||
<Text style={styles.sheetSelectText}>{feature.label}</Text>
|
||||
<Text style={styles.modeBadgeText}>{feature.value ? "On" : "Off"}</Text>
|
||||
<Text style={styles.modeBadgeText}>
|
||||
{feature.value ? t("agentControls.features.on") : t("agentControls.features.off")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -1344,8 +1361,6 @@ function ThinkingComboboxOption({
|
||||
);
|
||||
}
|
||||
|
||||
const FEATURES_SHEET_HEADER: SheetHeader = { title: "Features" };
|
||||
|
||||
export const AgentControls = memo(function AgentControls({
|
||||
agentId,
|
||||
serverId,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type ComponentType,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
@@ -108,6 +109,7 @@ function AgentModeControlView({
|
||||
disabled = false,
|
||||
}: AgentModeControlViewProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -182,14 +184,14 @@ function AgentModeControlView({
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: "Mode",
|
||||
title: t("agentControls.mode.title"),
|
||||
search: {
|
||||
onChange: setSearchQuery,
|
||||
placeholder: "Search modes...",
|
||||
placeholder: t("agentControls.mode.searchPlaceholder"),
|
||||
testID: "mode-search-input",
|
||||
},
|
||||
}),
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!selectedMode) return null;
|
||||
@@ -203,7 +205,9 @@ function AgentModeControlView({
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select agent mode (${selectedModeLabel})`}
|
||||
accessibilityLabel={t("agentControls.mode.selectWithValue", {
|
||||
value: selectedModeLabel,
|
||||
})}
|
||||
testID="mode-control"
|
||||
>
|
||||
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
|
||||
|
||||
@@ -3,17 +3,17 @@ import {
|
||||
formatAgentModeLabel,
|
||||
getFeatureHighlightColor,
|
||||
getFeatureTooltip,
|
||||
getAgentControlHint,
|
||||
getAgentControlHintKey,
|
||||
formatThinkingOptionLabel,
|
||||
normalizeModelId,
|
||||
resolveAgentModelSelection,
|
||||
} from "./utils";
|
||||
|
||||
describe("getAgentControlHint", () => {
|
||||
it("explains what each editable agent control does", () => {
|
||||
expect(getAgentControlHint("thinking")).toBe("Thinking mode");
|
||||
expect(getAgentControlHint("model")).toBe("Change model");
|
||||
expect(getAgentControlHint("mode")).toBe("Change permission mode");
|
||||
describe("getAgentControlHintKey", () => {
|
||||
it("returns translation keys for each editable agent control hint", () => {
|
||||
expect(getAgentControlHintKey("thinking")).toBe("agentControls.hints.thinking");
|
||||
expect(getAgentControlHintKey("model")).toBe("agentControls.hints.model");
|
||||
expect(getAgentControlHintKey("mode")).toBe("agentControls.hints.mode");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export type ExplainedAgentControl = "mode" | "model" | "thinking";
|
||||
export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow";
|
||||
export type AgentControlHintKey =
|
||||
| "agentControls.hints.thinking"
|
||||
| "agentControls.hints.model"
|
||||
| "agentControls.hints.mode";
|
||||
|
||||
export function getAgentControlHint(selector: ExplainedAgentControl): string {
|
||||
export function getAgentControlHintKey(selector: ExplainedAgentControl): AgentControlHintKey {
|
||||
switch (selector) {
|
||||
case "thinking":
|
||||
return "Thinking mode";
|
||||
return "agentControls.hints.thinking";
|
||||
case "model":
|
||||
return "Change model";
|
||||
return "agentControls.hints.model";
|
||||
case "mode":
|
||||
return "Change permission mode";
|
||||
return "agentControls.hints.mode";
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
@@ -79,7 +84,7 @@ export function formatThinkingOptionLabel(option: ControlLabelInput): string {
|
||||
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
|
||||
|
||||
if (compactId === "xhigh" || compactLabel === "xhigh") {
|
||||
return "Extra high";
|
||||
return i18n.t("agentControls.thinking.extraHigh");
|
||||
}
|
||||
|
||||
return formatControlLabel(option, true);
|
||||
@@ -143,17 +148,19 @@ function resolveModelDisplay(
|
||||
selectedModel: AgentModelDefinition | null,
|
||||
preferredModelId: string | null,
|
||||
fallbackModel: AgentModelDefinition | null,
|
||||
unknownModelLabel: string,
|
||||
): { activeModelId: string | null; displayModel: string } {
|
||||
return {
|
||||
activeModelId: selectedModel?.id ?? preferredModelId ?? null,
|
||||
displayModel:
|
||||
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model",
|
||||
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? unknownModelLabel,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveThinkingDisplay(
|
||||
effectiveThinking: ThinkingOption | null,
|
||||
selectedThinkingId: string | null,
|
||||
unknownThinkingLabel: string,
|
||||
): string {
|
||||
if (effectiveThinking) {
|
||||
return formatThinkingOptionLabel(effectiveThinking);
|
||||
@@ -163,7 +170,7 @@ function resolveThinkingDisplay(
|
||||
return formatThinkingOptionLabel({ id: selectedThinkingId });
|
||||
}
|
||||
|
||||
return "Unknown";
|
||||
return unknownThinkingLabel;
|
||||
}
|
||||
|
||||
export function resolveAgentModelSelection(input: {
|
||||
@@ -189,13 +196,18 @@ export function resolveAgentModelSelection(input: {
|
||||
selectedModel,
|
||||
preferredModelId,
|
||||
fallbackModel,
|
||||
i18n.t("agentControls.model.unknown"),
|
||||
);
|
||||
|
||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
||||
const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel);
|
||||
const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId);
|
||||
const selectedThinkingId = effectiveThinking?.id ?? null;
|
||||
const displayThinking = resolveThinkingDisplay(effectiveThinking, selectedThinkingId);
|
||||
const displayThinking = resolveThinkingDisplay(
|
||||
effectiveThinking,
|
||||
selectedThinkingId,
|
||||
i18n.t("agentControls.thinking.unknown"),
|
||||
);
|
||||
|
||||
return {
|
||||
selectedModel,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { MessageSquareCode, MousePointer2 } from "lucide-react-native";
|
||||
import type {
|
||||
@@ -231,14 +232,15 @@ function WorkspaceAttachmentPill({
|
||||
onOpen,
|
||||
onRemove,
|
||||
}: WorkspaceAttachmentPillProps) {
|
||||
const { t } = useTranslation();
|
||||
let label: string;
|
||||
if (attachment.kind === "browser_element") {
|
||||
label = `Element · ${attachment.attachment.tag}`;
|
||||
label = t("composer.attachments.browserElement", { tag: attachment.attachment.tag });
|
||||
} else {
|
||||
label =
|
||||
attachment.commentCount === 1
|
||||
? "Review · 1 comment"
|
||||
: `Review · ${attachment.commentCount} comments`;
|
||||
? t("message.attachments.reviewOne")
|
||||
: t("message.attachments.reviewMany", { count: attachment.commentCount });
|
||||
}
|
||||
const handleOpen = useCallback(() => {
|
||||
onOpen(attachment);
|
||||
@@ -253,13 +255,13 @@ function WorkspaceAttachmentPill({
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={
|
||||
attachment.kind === "browser_element"
|
||||
? "Open browser element attachment"
|
||||
: "Open review attachment"
|
||||
? t("composer.attachments.openBrowserElement")
|
||||
: t("composer.attachments.openReview")
|
||||
}
|
||||
removeAccessibilityLabel={
|
||||
attachment.kind === "browser_element"
|
||||
? "Remove browser element attachment"
|
||||
: "Remove review attachment"
|
||||
? t("composer.attachments.removeBrowserElement")
|
||||
: t("composer.attachments.removeReview")
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useReducer } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
@@ -105,6 +106,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
onCreateSuccess,
|
||||
onCreateError,
|
||||
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
|
||||
const { t } = useTranslation();
|
||||
const [machine, dispatch] = useReducer(
|
||||
reducer,
|
||||
initialAttempt,
|
||||
@@ -163,7 +165,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
|
||||
const pendingServerId = getPendingServerId();
|
||||
if (!pendingServerId) {
|
||||
const error = new Error("No host selected");
|
||||
const error = new Error(t("composer.errors.noHostSelected"));
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||
throw error;
|
||||
}
|
||||
@@ -204,7 +206,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
|
||||
await onCreateSuccess({ result: createResult.result, attempt });
|
||||
} catch (error) {
|
||||
const resolved = error instanceof Error ? error : new Error("Failed to create agent");
|
||||
const resolved =
|
||||
error instanceof Error ? error : new Error(t("composer.errors.failedToCreateAgent"));
|
||||
dispatch({ type: "CREATE_FAILED", message: resolved.message });
|
||||
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
|
||||
clearPendingCreateAttempt({ draftId });
|
||||
@@ -222,6 +225,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
onBeforeSubmit,
|
||||
onCreateError,
|
||||
onCreateSuccess,
|
||||
t,
|
||||
updatePendingAgentId,
|
||||
],
|
||||
);
|
||||
@@ -229,7 +233,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
const handleCreateFromInput = useCallback(
|
||||
async ({ text, attachments, cwd }: SubmitContext) => {
|
||||
if (isSubmitting) {
|
||||
throw new Error("Already loading");
|
||||
throw new Error(t("composer.errors.alreadyLoading"));
|
||||
}
|
||||
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
|
||||
@@ -238,7 +242,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
|
||||
const trimmedPrompt = text.trim();
|
||||
if (!trimmedPrompt && !allowEmptyText) {
|
||||
const error = new Error("Initial prompt is required");
|
||||
const error = new Error(t("composer.errors.initialPromptRequired"));
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||
throw error;
|
||||
}
|
||||
@@ -256,7 +260,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
|
||||
const pendingServerId = getPendingServerId();
|
||||
if (!pendingServerId) {
|
||||
const error = new Error("No host selected");
|
||||
const error = new Error(t("composer.errors.noHostSelected"));
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||
throw error;
|
||||
}
|
||||
@@ -294,6 +298,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
onCreateStart,
|
||||
runCreateAttempt,
|
||||
setPendingCreateAttempt,
|
||||
t,
|
||||
validateBeforeSubmit,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Import as ImportIcon } from "lucide-react-native";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -13,6 +14,7 @@ interface ComposerImportPillProps {
|
||||
}
|
||||
|
||||
export function ComposerImportPill({ onPress, disabled = false }: ComposerImportPillProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
@@ -22,7 +24,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
|
||||
<Pressable
|
||||
testID="composer-import-agent-pill"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Import session"
|
||||
accessibilityLabel={t("importSession.title")}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
onHoverIn={handleHoverIn}
|
||||
@@ -31,7 +33,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
|
||||
>
|
||||
<ThemedImportIcon size={14} uniProps={iconColorMapping} />
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
Import session
|
||||
{t("importSession.title")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Keyboard, ScrollView, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -130,6 +131,8 @@ async function submitDraftCreateRequest(input: {
|
||||
effectiveThinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
};
|
||||
hostDisconnectedMessage: string;
|
||||
selectModelMessage: string;
|
||||
}): Promise<{ agentId: string | null; result: AgentSnapshotPayload }> {
|
||||
const {
|
||||
attempt,
|
||||
@@ -146,12 +149,12 @@ async function submitDraftCreateRequest(input: {
|
||||
invariant(workspaceDirectory, "Workspace directory is required");
|
||||
invariant(workspaceExecutionAuthority, "Workspace authority is required");
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
throw new Error(input.hostDisconnectedMessage);
|
||||
}
|
||||
|
||||
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
throw new Error("Select a model");
|
||||
throw new Error(input.selectModelMessage);
|
||||
}
|
||||
const modeIdOverride = resolveDraftModeIdOverride({
|
||||
autoSubmitConfig,
|
||||
@@ -199,6 +202,7 @@ function buildDraftAgentSnapshot(input: {
|
||||
selectedProvider: string | null;
|
||||
agentControls: { features?: Agent["features"] };
|
||||
};
|
||||
selectModelMessage: string;
|
||||
}): Agent {
|
||||
const { attempt, serverId, tabId, workspaceDirectory, autoSubmitConfig, composerState } = input;
|
||||
invariant(workspaceDirectory, "Workspace directory is required");
|
||||
@@ -213,7 +217,7 @@ function buildDraftAgentSnapshot(input: {
|
||||
});
|
||||
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
throw new Error("Select a model");
|
||||
throw new Error(input.selectModelMessage);
|
||||
}
|
||||
return {
|
||||
serverId,
|
||||
@@ -309,6 +313,7 @@ export function WorkspaceDraftAgentTab({
|
||||
onOpenWorkspaceFile,
|
||||
onOpenImportSheet,
|
||||
}: WorkspaceDraftAgentTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -456,6 +461,7 @@ export function WorkspaceDraftAgentTab({
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
}),
|
||||
createRequest: async ({ attempt, text, images, attachments }) =>
|
||||
submitDraftCreateRequest({
|
||||
@@ -468,6 +474,8 @@ export function WorkspaceDraftAgentTab({
|
||||
workspaceExecutionAuthority,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
}),
|
||||
onCreateSuccess: ({ result }) => {
|
||||
clearDraftInput("sent");
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Image,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
@@ -144,8 +146,10 @@ function resolveCompactLayout(override: boolean | undefined, formFactor: boolean
|
||||
return override ?? formFactor;
|
||||
}
|
||||
|
||||
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
|
||||
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
|
||||
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean, t: TFunction): string {
|
||||
return isDesktopWebBreakpoint
|
||||
? t("composer.placeholders.desktop")
|
||||
: t("composer.placeholders.mobile");
|
||||
}
|
||||
|
||||
function resolveGithubSearchEnabled(
|
||||
@@ -252,6 +256,12 @@ interface RenderAttachmentTrayArgs {
|
||||
isComposerLocked: boolean;
|
||||
handleOpenAttachment: (attachment: ComposerAttachment) => void;
|
||||
handleRemoveAttachment: (index: number) => void;
|
||||
labels: {
|
||||
openImage: string;
|
||||
removeImage: string;
|
||||
openGithub: (kind: string, number: number) => string;
|
||||
removeGithub: (kind: string, number: number) => string;
|
||||
};
|
||||
}
|
||||
|
||||
function renderComposerFooter(
|
||||
@@ -272,8 +282,13 @@ function renderComposerFooter(
|
||||
}
|
||||
|
||||
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
|
||||
const { selectedAttachments, isComposerLocked, handleOpenAttachment, handleRemoveAttachment } =
|
||||
args;
|
||||
const {
|
||||
selectedAttachments,
|
||||
isComposerLocked,
|
||||
handleOpenAttachment,
|
||||
handleRemoveAttachment,
|
||||
labels,
|
||||
} = args;
|
||||
if (selectedAttachments.length === 0) return null;
|
||||
return (
|
||||
<View style={styles.attachmentTray} testID="composer-attachment-tray">
|
||||
@@ -284,6 +299,7 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu
|
||||
disabled: isComposerLocked,
|
||||
onOpen: handleOpenAttachment,
|
||||
onRemove: handleRemoveAttachment,
|
||||
labels,
|
||||
}),
|
||||
)}
|
||||
</View>
|
||||
@@ -294,10 +310,13 @@ interface RenderQueueTrackArgs {
|
||||
queuedMessages: readonly QueuedMessage[];
|
||||
handleEditQueuedMessage: (id: string) => void;
|
||||
handleSendQueuedNow: (id: string) => Promise<void>;
|
||||
editLabel: string;
|
||||
sendNowLabel: string;
|
||||
}
|
||||
|
||||
function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
|
||||
const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow } = args;
|
||||
const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow, editLabel, sendNowLabel } =
|
||||
args;
|
||||
if (queuedMessages.length === 0) return null;
|
||||
return (
|
||||
<View style={styles.queueTrack}>
|
||||
@@ -307,6 +326,8 @@ function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
|
||||
item={item}
|
||||
onEdit={handleEditQueuedMessage}
|
||||
onSendNow={handleSendQueuedNow}
|
||||
editLabel={editLabel}
|
||||
sendNowLabel={sendNowLabel}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -319,10 +340,11 @@ interface RenderComposerAttachmentPillArgs {
|
||||
disabled: boolean;
|
||||
onOpen: (attachment: ComposerAttachment) => void;
|
||||
onRemove: (index: number) => void;
|
||||
labels: RenderAttachmentTrayArgs["labels"];
|
||||
}
|
||||
|
||||
function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): ReactElement {
|
||||
const { attachment, index, disabled, onOpen, onRemove } = args;
|
||||
const { attachment, index, disabled, onOpen, onRemove, labels } = args;
|
||||
if (attachment.kind === "image") {
|
||||
return (
|
||||
<ImageAttachmentPill
|
||||
@@ -332,6 +354,8 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
|
||||
disabled={disabled}
|
||||
onOpen={onOpen}
|
||||
onRemove={onRemove}
|
||||
openLabel={labels.openImage}
|
||||
removeLabel={labels.removeImage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -352,6 +376,8 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
|
||||
disabled={disabled}
|
||||
onOpen={onOpen}
|
||||
onRemove={onRemove}
|
||||
openLabel={labels.openGithub}
|
||||
removeLabel={labels.removeGithub}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -472,9 +498,17 @@ interface QueuedMessageRowProps {
|
||||
item: QueuedMessage;
|
||||
onEdit: (id: string) => void;
|
||||
onSendNow: (id: string) => void;
|
||||
editLabel: string;
|
||||
sendNowLabel: string;
|
||||
}
|
||||
|
||||
function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
|
||||
function QueuedMessageRow({
|
||||
item,
|
||||
onEdit,
|
||||
onSendNow,
|
||||
editLabel,
|
||||
sendNowLabel,
|
||||
}: QueuedMessageRowProps) {
|
||||
const handleEdit = useCallback(() => {
|
||||
onEdit(item.id);
|
||||
}, [onEdit, item.id]);
|
||||
@@ -490,7 +524,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
|
||||
<Pressable
|
||||
onPress={handleEdit}
|
||||
style={styles.queueActionButton}
|
||||
accessibilityLabel="Edit queued message"
|
||||
accessibilityLabel={editLabel}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
|
||||
@@ -498,7 +532,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
|
||||
<Pressable
|
||||
onPress={handleSendNow}
|
||||
style={QUEUE_SEND_BUTTON_STYLE}
|
||||
accessibilityLabel="Send queued message now"
|
||||
accessibilityLabel={sendNowLabel}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ThemedArrowUp size={ICON_SIZE.sm} uniProps={iconAccentForegroundMapping} />
|
||||
@@ -523,6 +557,8 @@ interface ImageAttachmentPillProps {
|
||||
disabled: boolean;
|
||||
onOpen: (attachment: ComposerAttachment) => void;
|
||||
onRemove: (index: number) => void;
|
||||
openLabel: string;
|
||||
removeLabel: string;
|
||||
}
|
||||
|
||||
function ImageAttachmentPill({
|
||||
@@ -531,6 +567,8 @@ function ImageAttachmentPill({
|
||||
disabled,
|
||||
onOpen,
|
||||
onRemove,
|
||||
openLabel,
|
||||
removeLabel,
|
||||
}: ImageAttachmentPillProps) {
|
||||
const handleOpen = useCallback(() => {
|
||||
onOpen(attachment);
|
||||
@@ -543,8 +581,8 @@ function ImageAttachmentPill({
|
||||
testID="composer-image-attachment-pill"
|
||||
onOpen={handleOpen}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel="Open image attachment"
|
||||
removeAccessibilityLabel="Remove image attachment"
|
||||
openAccessibilityLabel={openLabel}
|
||||
removeAccessibilityLabel={removeLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ImageAttachmentThumbnail image={attachment.metadata} />
|
||||
@@ -558,6 +596,8 @@ interface GithubAttachmentPillProps {
|
||||
disabled: boolean;
|
||||
onOpen: (attachment: ComposerAttachment) => void;
|
||||
onRemove: (index: number) => void;
|
||||
openLabel: (kind: string, number: number) => string;
|
||||
removeLabel: (kind: string, number: number) => string;
|
||||
}
|
||||
|
||||
function GithubAttachmentPill({
|
||||
@@ -566,6 +606,8 @@ function GithubAttachmentPill({
|
||||
disabled,
|
||||
onOpen,
|
||||
onRemove,
|
||||
openLabel,
|
||||
removeLabel,
|
||||
}: GithubAttachmentPillProps) {
|
||||
const item = attachment.item;
|
||||
const kindLabel = item.kind === "pr" ? "PR" : "issue";
|
||||
@@ -580,8 +622,8 @@ function GithubAttachmentPill({
|
||||
testID="composer-github-attachment-pill"
|
||||
onOpen={handleOpen}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
|
||||
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
|
||||
openAccessibilityLabel={openLabel(kindLabel, item.number)}
|
||||
removeAccessibilityLabel={removeLabel(kindLabel, item.number)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={styles.githubPillBody}>
|
||||
@@ -693,8 +735,6 @@ interface ComposerProps {
|
||||
}
|
||||
|
||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
||||
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||
const StableMessageInput = memo(MessageInput);
|
||||
|
||||
function resolveContextWindowValues(
|
||||
@@ -714,6 +754,7 @@ interface ComposerCancelButtonProps {
|
||||
isConnected: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
agentInterruptKeys: ReturnType<typeof useShortcutKeys>;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function ComposerCancelButton({
|
||||
@@ -723,8 +764,11 @@ function ComposerCancelButton({
|
||||
isConnected,
|
||||
isCancellingAgent,
|
||||
agentInterruptKeys,
|
||||
t,
|
||||
}: ComposerCancelButtonProps) {
|
||||
const accessibilityLabel = isCancellingAgent ? "Canceling agent" : "Stop agent";
|
||||
const accessibilityLabel = isCancellingAgent
|
||||
? t("composer.cancel.cancelingAgent")
|
||||
: t("composer.cancel.stopAgent");
|
||||
const icon = isCancellingAgent ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
@@ -744,7 +788,7 @@ function ComposerCancelButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||
<Text style={styles.tooltipText}>{t("composer.cancel.interrupt")}</Text>
|
||||
{shortcutNode}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
@@ -777,6 +821,7 @@ interface ComposerVoiceModeButtonProps {
|
||||
state: PressableStateCallbackType & { hovered?: boolean },
|
||||
) => (object | undefined)[];
|
||||
voiceToggleKeys: ReturnType<typeof useShortcutKeys>;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
|
||||
@@ -819,6 +864,7 @@ function ComposerVoiceModeButton({
|
||||
isVoiceSwitching,
|
||||
realtimeVoiceButtonStyle,
|
||||
voiceToggleKeys,
|
||||
t,
|
||||
}: ComposerVoiceModeButtonProps) {
|
||||
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
|
||||
const renderTriggerContent = useCallback(
|
||||
@@ -836,7 +882,7 @@ function ComposerVoiceModeButton({
|
||||
<TooltipTrigger
|
||||
onPress={handleToggleRealtimeVoice}
|
||||
disabled={!isConnected || isVoiceSwitching}
|
||||
accessibilityLabel="Enable Voice mode"
|
||||
accessibilityLabel={t("composer.voice.enableVoiceMode")}
|
||||
accessibilityRole="button"
|
||||
style={realtimeVoiceButtonStyle}
|
||||
>
|
||||
@@ -844,7 +890,7 @@ function ComposerVoiceModeButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Voice mode</Text>
|
||||
<Text style={styles.tooltipText}>{t("composer.voice.voiceMode")}</Text>
|
||||
{shortcutNode}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
@@ -887,6 +933,7 @@ export function Composer({
|
||||
externalKeyboardShift,
|
||||
isCompactLayout: isCompactLayoutOverride,
|
||||
}: ComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const buttonIconSize = resolveComposerButtonIconSize();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -920,7 +967,7 @@ export function Composer({
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
|
||||
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t);
|
||||
const userInput = value;
|
||||
const setUserInput = onChangeText;
|
||||
const {
|
||||
@@ -1070,11 +1117,11 @@ export function Composer({
|
||||
return;
|
||||
}
|
||||
if (!sendAgentMessageRef.current) {
|
||||
throw new Error("Host is not connected");
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
await sendAgentMessageRef.current(agentIdRef.current, text, submitAttachments);
|
||||
},
|
||||
[cwd, onMessageSent],
|
||||
[cwd, onMessageSent, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1088,7 +1135,7 @@ export function Composer({
|
||||
sendAttachments: ComposerAttachment[],
|
||||
) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const stream: AgentStreamWriter = {
|
||||
getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id),
|
||||
@@ -1106,7 +1153,7 @@ export function Composer({
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
};
|
||||
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead]);
|
||||
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead, t]);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitMessageRef.current = onSubmitMessage;
|
||||
@@ -1181,6 +1228,7 @@ export function Composer({
|
||||
onSubmitError: (error) => {
|
||||
console.error("[AgentInput] Failed to send message:", error);
|
||||
},
|
||||
failedToSendMessage: t("composer.errors.failedToSend"),
|
||||
});
|
||||
completeSubmit({
|
||||
result,
|
||||
@@ -1198,6 +1246,7 @@ export function Composer({
|
||||
setUserInput,
|
||||
submitBehavior,
|
||||
submitMessage,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1376,12 +1425,13 @@ export function Composer({
|
||||
queue: queueWriter,
|
||||
submitMessage: ({ text, attachments: queuedAttachments }) =>
|
||||
submitMessage(text, queuedAttachments),
|
||||
failedToSendMessage: t("composer.errors.failedToSend"),
|
||||
});
|
||||
if (result.status === "failed") {
|
||||
setSendError(result.errorMessage);
|
||||
}
|
||||
},
|
||||
[agentId, queueWriter, submitMessage],
|
||||
[agentId, queueWriter, submitMessage, t],
|
||||
);
|
||||
|
||||
const handleQueue = useCallback(
|
||||
@@ -1433,6 +1483,7 @@ export function Composer({
|
||||
isConnected={isConnected}
|
||||
isCancellingAgent={isCancellingAgent}
|
||||
agentInterruptKeys={agentInterruptKeys}
|
||||
t={t}
|
||||
/>
|
||||
),
|
||||
[
|
||||
@@ -1445,6 +1496,7 @@ export function Composer({
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
isProcessing,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1463,6 +1515,7 @@ export function Composer({
|
||||
isVoiceSwitching={isVoiceSwitching}
|
||||
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
|
||||
voiceToggleKeys={voiceToggleKeys}
|
||||
t={t}
|
||||
cancelButton={cancelButton}
|
||||
/>
|
||||
),
|
||||
@@ -1479,6 +1532,7 @@ export function Composer({
|
||||
isVoiceModeForAgent,
|
||||
isVoiceSwitching,
|
||||
realtimeVoiceButtonStyle,
|
||||
t,
|
||||
voiceToggleKeys,
|
||||
],
|
||||
);
|
||||
@@ -1528,7 +1582,7 @@ export function Composer({
|
||||
() => [
|
||||
{
|
||||
id: "image",
|
||||
label: "Add image",
|
||||
label: t("composer.attachments.addImage"),
|
||||
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
|
||||
onSelect: () => {
|
||||
void handlePickImage();
|
||||
@@ -1536,14 +1590,14 @@ export function Composer({
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
label: "Add issue or PR",
|
||||
label: t("composer.attachments.addIssueOrPr"),
|
||||
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
|
||||
onSelect: () => {
|
||||
setIsGithubPickerOpen(true);
|
||||
},
|
||||
},
|
||||
],
|
||||
[handlePickImage],
|
||||
[handlePickImage, t],
|
||||
);
|
||||
|
||||
const handleToggleGithubItem = useCallback(
|
||||
@@ -1641,13 +1695,28 @@ export function Composer({
|
||||
isComposerLocked,
|
||||
handleOpenAttachment,
|
||||
handleRemoveAttachment,
|
||||
labels: {
|
||||
openImage: t("composer.attachments.openImage"),
|
||||
removeImage: t("composer.attachments.removeImage"),
|
||||
openGithub: (kind: string, number: number) =>
|
||||
t("composer.attachments.openGithub", { kind, number }),
|
||||
removeGithub: (kind: string, number: number) =>
|
||||
t("composer.attachments.removeGithub", { kind, number }),
|
||||
},
|
||||
}),
|
||||
[handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments],
|
||||
[handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t],
|
||||
);
|
||||
|
||||
const queueList = useMemo(
|
||||
() => renderQueueTrack({ queuedMessages, handleEditQueuedMessage, handleSendQueuedNow }),
|
||||
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages],
|
||||
() =>
|
||||
renderQueueTrack({
|
||||
queuedMessages,
|
||||
handleEditQueuedMessage,
|
||||
handleSendQueuedNow,
|
||||
editLabel: t("composer.attachments.editQueuedMessage"),
|
||||
sendNowLabel: t("composer.attachments.sendQueuedMessageNow"),
|
||||
}),
|
||||
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages, t],
|
||||
);
|
||||
|
||||
const messageInputContainerRef = useRef<View>(null);
|
||||
@@ -1660,8 +1729,8 @@ export function Composer({
|
||||
[sendError],
|
||||
);
|
||||
const githubEmptyText = githubSearchResultsQuery.isFetching
|
||||
? "Searching..."
|
||||
: "No results found.";
|
||||
? t("composer.github.searching")
|
||||
: t("composer.github.noResults");
|
||||
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
|
||||
|
||||
return (
|
||||
@@ -1732,8 +1801,8 @@ export function Composer({
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder="Search issues and PRs..."
|
||||
title="Attach issue or PR"
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
|
||||
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
|
||||
@@ -60,6 +61,12 @@ import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useComposerHeightMirror } from "./height-mirror";
|
||||
import {
|
||||
resolveSendTooltipLabel,
|
||||
resolveSubmitAccessibilityLabel,
|
||||
resolveVoiceAccessibilityLabel,
|
||||
resolveVoiceTooltipText,
|
||||
} from "./labels";
|
||||
import { computeCanStartDictation } from "./state";
|
||||
|
||||
export interface AttachmentMenuItem {
|
||||
@@ -142,7 +149,6 @@ const MIN_INPUT_HEIGHT_DESKTOP = 46;
|
||||
const DEFAULT_MAX_INPUT_HEIGHT = 160;
|
||||
const MAX_INPUT_VIEWPORT_RATIO = 0.5;
|
||||
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
|
||||
const ATTACHMENT_SHEET_HEADER: SheetHeader = { title: "Add attachment" };
|
||||
const ATTACHMENT_SHEET_SNAP_POINTS = ["34%", "45%"];
|
||||
|
||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
@@ -259,18 +265,24 @@ function AttachmentDropdown({
|
||||
attachButtonStyle,
|
||||
renderAttachButtonIcon,
|
||||
attachmentMenuItems,
|
||||
addAttachmentLabel,
|
||||
}: {
|
||||
isConnected: boolean;
|
||||
disabled: boolean;
|
||||
attachButtonStyle: React.ComponentProps<typeof DropdownMenuTrigger>["style"];
|
||||
renderAttachButtonIcon: (input: { hovered?: boolean }) => React.ReactElement;
|
||||
attachmentMenuItems: AttachmentMenuItem[];
|
||||
addAttachmentLabel: string;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
useDismissKeyboardOnOpen(isSheetOpen, isCompact);
|
||||
|
||||
const isButtonDisabled = !isConnected || disabled;
|
||||
const attachmentSheetHeader = useMemo<SheetHeader>(
|
||||
() => ({ title: addAttachmentLabel }),
|
||||
[addAttachmentLabel],
|
||||
);
|
||||
const handleOpenSheet = useCallback(() => {
|
||||
if (isButtonDisabled) return;
|
||||
setIsSheetOpen(true);
|
||||
@@ -306,7 +318,7 @@ function AttachmentDropdown({
|
||||
<>
|
||||
<Pressable
|
||||
disabled={isButtonDisabled}
|
||||
accessibilityLabel="Add attachment"
|
||||
accessibilityLabel={addAttachmentLabel}
|
||||
accessibilityRole="button"
|
||||
testID="message-input-attach-button"
|
||||
onPress={handleOpenSheet}
|
||||
@@ -315,7 +327,7 @@ function AttachmentDropdown({
|
||||
{renderMobileAttachButtonIcon}
|
||||
</Pressable>
|
||||
<AdaptiveModalSheet
|
||||
header={ATTACHMENT_SHEET_HEADER}
|
||||
header={attachmentSheetHeader}
|
||||
visible={isSheetOpen}
|
||||
onClose={handleCloseSheet}
|
||||
snapPoints={ATTACHMENT_SHEET_SNAP_POINTS}
|
||||
@@ -333,7 +345,7 @@ function AttachmentDropdown({
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger
|
||||
disabled={isButtonDisabled}
|
||||
accessibilityLabel="Add attachment"
|
||||
accessibilityLabel={addAttachmentLabel}
|
||||
accessibilityRole="button"
|
||||
testID="message-input-attach-button"
|
||||
style={attachButtonStyle}
|
||||
@@ -342,7 +354,7 @@ function AttachmentDropdown({
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Add attachment</Text>
|
||||
<Text style={styles.tooltipText}>{addAttachmentLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
@@ -429,49 +441,6 @@ function SendButtonContent({
|
||||
return <ThemedArrowUp size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
|
||||
}
|
||||
|
||||
function resolveSubmitAccessibilityLabel(input: {
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
canPressLoadingButton: boolean;
|
||||
defaultActionQueues: boolean;
|
||||
isAgentRunning: boolean;
|
||||
}): string {
|
||||
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
|
||||
if (input.canPressLoadingButton) return "Interrupt agent";
|
||||
if (input.defaultActionQueues) return "Queue message";
|
||||
if (input.isAgentRunning) return "Send and interrupt";
|
||||
return "Send message";
|
||||
}
|
||||
|
||||
function resolveVoiceAccessibilityLabel(input: {
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
isMuted: boolean;
|
||||
isDictating: boolean;
|
||||
}): string {
|
||||
if (input.isRealtimeVoiceForCurrentAgent) {
|
||||
return input.isMuted ? "Unmute Voice mode" : "Mute Voice mode";
|
||||
}
|
||||
if (input.isDictating) return "Stop dictation";
|
||||
return "Start dictation";
|
||||
}
|
||||
|
||||
function resolveVoiceTooltipText(input: {
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
isMuted: boolean;
|
||||
}): string {
|
||||
if (input.isRealtimeVoiceForCurrentAgent) {
|
||||
return input.isMuted ? "Unmute voice" : "Mute voice";
|
||||
}
|
||||
return "Dictation";
|
||||
}
|
||||
|
||||
function resolveSendTooltipLabel(input: {
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
defaultActionQueues: boolean;
|
||||
}): string {
|
||||
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
|
||||
return input.defaultActionQueues ? "Queue" : "Send";
|
||||
}
|
||||
|
||||
interface DesktopKeyPressContext {
|
||||
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined;
|
||||
submitOnEnter: boolean;
|
||||
@@ -749,14 +718,16 @@ function MessageInputOverlay({
|
||||
function FocusHint({
|
||||
visible,
|
||||
focusInputKeys,
|
||||
label,
|
||||
}: {
|
||||
visible: boolean;
|
||||
focusInputKeys: ShortcutChord | null | undefined;
|
||||
label: string;
|
||||
}) {
|
||||
if (!visible || !focusInputKeys) return null;
|
||||
if (!visible || !focusInputKeys || !label.trim()) return null;
|
||||
return (
|
||||
<Text style={styles.focusHintText} pointerEvents="none">
|
||||
{formatShortcut(focusInputKeys[0], getShortcutOs())} to focus
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -812,9 +783,8 @@ function SendButtonTooltip({
|
||||
isSubmitLoading,
|
||||
submitIcon,
|
||||
buttonIconSize,
|
||||
submitButtonAccessibilityLabel,
|
||||
defaultActionQueues,
|
||||
sendKeys,
|
||||
sendTooltipLabel,
|
||||
}: {
|
||||
shouldShow: boolean;
|
||||
canPressLoadingButton: boolean;
|
||||
@@ -826,9 +796,8 @@ function SendButtonTooltip({
|
||||
isSubmitLoading: boolean;
|
||||
submitIcon: "arrow" | "return";
|
||||
buttonIconSize: number;
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
defaultActionQueues: boolean;
|
||||
sendKeys: ShortcutChord | null | undefined;
|
||||
sendTooltipLabel: string;
|
||||
}) {
|
||||
if (!shouldShow) return null;
|
||||
return (
|
||||
@@ -847,10 +816,7 @@ function SendButtonTooltip({
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<SendTooltipBody
|
||||
label={resolveSendTooltipLabel({ submitButtonAccessibilityLabel, defaultActionQueues })}
|
||||
sendKeys={sendKeys}
|
||||
/>
|
||||
<SendTooltipBody label={sendTooltipLabel} sendKeys={sendKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -908,6 +874,7 @@ interface ToggleRealtimeVoiceContext {
|
||||
isAgentRunning: boolean;
|
||||
handleStopRealtimeVoice: () => Promise<unknown> | void;
|
||||
toast: { error: (msg: string) => void };
|
||||
interruptBeforeVoiceMessage: string;
|
||||
}
|
||||
|
||||
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
||||
@@ -920,7 +887,7 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
||||
return;
|
||||
}
|
||||
if (ctx.isAgentRunning) {
|
||||
ctx.toast.error("Interrupt the agent before starting voice mode");
|
||||
ctx.toast.error(ctx.interruptBeforeVoiceMessage);
|
||||
return;
|
||||
}
|
||||
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
|
||||
@@ -1199,7 +1166,7 @@ interface ResolvedMessageInputProps {
|
||||
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
|
||||
client: DaemonClient | null;
|
||||
isReadyForDictation: boolean | undefined;
|
||||
placeholder: string;
|
||||
placeholder: string | undefined;
|
||||
autoFocus: boolean;
|
||||
autoFocusKey: string | undefined;
|
||||
disabled: boolean;
|
||||
@@ -1239,7 +1206,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
||||
onAddImages: props.onAddImages,
|
||||
client: props.client,
|
||||
isReadyForDictation: props.isReadyForDictation,
|
||||
placeholder: props.placeholder ?? "Message...",
|
||||
placeholder: props.placeholder,
|
||||
autoFocus: props.autoFocus ?? false,
|
||||
autoFocusKey: props.autoFocusKey,
|
||||
disabled: props.disabled ?? false,
|
||||
@@ -1308,6 +1275,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
inputWrapperStyle,
|
||||
attachmentSlot,
|
||||
} = resolveMessageInputProps(props);
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const maxInputHeight = resolveMaxInputHeight(windowHeight);
|
||||
@@ -1564,12 +1532,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isAgentRunning,
|
||||
handleStopRealtimeVoice,
|
||||
toast,
|
||||
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
|
||||
});
|
||||
}, [
|
||||
disabled,
|
||||
handleStopRealtimeVoice,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
t,
|
||||
toast,
|
||||
voice,
|
||||
voiceAgentId,
|
||||
@@ -1747,17 +1717,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
canPressLoadingButton,
|
||||
defaultActionQueues,
|
||||
isAgentRunning,
|
||||
t,
|
||||
});
|
||||
|
||||
const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
isMuted: Boolean(voice?.isMuted),
|
||||
isDictating,
|
||||
t,
|
||||
});
|
||||
|
||||
const voiceTooltipText = resolveVoiceTooltipText({
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
isMuted: Boolean(voice?.isMuted),
|
||||
t,
|
||||
});
|
||||
|
||||
const sendTooltipLabel = resolveSendTooltipLabel({
|
||||
submitButtonAccessibilityLabel,
|
||||
defaultActionQueues,
|
||||
t,
|
||||
});
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
@@ -1854,9 +1833,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
ref={textInputRef}
|
||||
value={value}
|
||||
onChangeText={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
placeholder={placeholder ?? t("composer.placeholders.fallback")}
|
||||
uniProps={textInputPlaceholderColorMapping}
|
||||
accessibilityLabel="Message agent..."
|
||||
accessibilityLabel={t("composer.input.accessibilityLabel")}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
style={textInputStyle}
|
||||
@@ -1872,6 +1851,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
<FocusHint
|
||||
visible={isWeb && isPaneFocused && !isInputFocused && !value}
|
||||
focusInputKeys={focusInputKeys}
|
||||
label={t("composer.input.focusHint", {
|
||||
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1885,6 +1867,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
attachButtonStyle={attachButtonStyle}
|
||||
renderAttachButtonIcon={renderAttachButtonIcon}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
addAttachmentLabel={t("composer.input.addAttachment")}
|
||||
/>
|
||||
{leftContent}
|
||||
</View>
|
||||
@@ -1915,9 +1898,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitIcon={submitIcon}
|
||||
buttonIconSize={buttonIconSize}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
defaultActionQueues={defaultActionQueues}
|
||||
sendKeys={sendKeys}
|
||||
sendTooltipLabel={sendTooltipLabel}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveSendTooltipLabel,
|
||||
resolveSubmitAccessibilityLabel,
|
||||
resolveVoiceAccessibilityLabel,
|
||||
resolveVoiceTooltipText,
|
||||
} from "./labels";
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
"composer.input.interruptAgent": "Interrupt agent",
|
||||
"composer.input.queueMessage": "Queue message",
|
||||
"composer.input.sendAndInterrupt": "Send and interrupt",
|
||||
"composer.input.sendMessage": "Send message",
|
||||
"composer.input.queue": "Queue",
|
||||
"composer.input.send": "Send",
|
||||
"composer.voice.unmuteVoiceMode": "Unmute Voice mode",
|
||||
"composer.voice.muteVoiceMode": "Mute Voice mode",
|
||||
"composer.voice.stopDictation": "Stop dictation",
|
||||
"composer.voice.startDictation": "Start dictation",
|
||||
"composer.voice.unmuteVoice": "Unmute voice",
|
||||
"composer.voice.muteVoice": "Mute voice",
|
||||
"composer.voice.dictation": "Dictation",
|
||||
};
|
||||
|
||||
const t = ((key: string) => translations[key] ?? key) as never;
|
||||
|
||||
describe("composer input labels", () => {
|
||||
it("resolves submit accessibility labels from translations", () => {
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: true,
|
||||
defaultActionQueues: false,
|
||||
isAgentRunning: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Interrupt agent");
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: true,
|
||||
isAgentRunning: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Queue message");
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: false,
|
||||
isAgentRunning: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Send and interrupt");
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: false,
|
||||
isAgentRunning: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Send message");
|
||||
});
|
||||
|
||||
it("keeps explicit submit labels untouched", () => {
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: "Run now",
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: false,
|
||||
isAgentRunning: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Run now");
|
||||
});
|
||||
|
||||
it("resolves voice labels from translations", () => {
|
||||
expect(
|
||||
resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent: true,
|
||||
isMuted: true,
|
||||
isDictating: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Unmute Voice mode");
|
||||
expect(
|
||||
resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent: true,
|
||||
isMuted: false,
|
||||
isDictating: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Mute Voice mode");
|
||||
expect(
|
||||
resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent: false,
|
||||
isMuted: false,
|
||||
isDictating: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Stop dictation");
|
||||
expect(
|
||||
resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent: false,
|
||||
isMuted: false,
|
||||
isDictating: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Start dictation");
|
||||
});
|
||||
|
||||
it("resolves tooltip labels from translations", () => {
|
||||
expect(
|
||||
resolveVoiceTooltipText({
|
||||
isRealtimeVoiceForCurrentAgent: false,
|
||||
isMuted: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Dictation");
|
||||
expect(
|
||||
resolveSendTooltipLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
defaultActionQueues: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Queue");
|
||||
expect(
|
||||
resolveSendTooltipLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
defaultActionQueues: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Send");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
export function resolveSubmitAccessibilityLabel(input: {
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
canPressLoadingButton: boolean;
|
||||
defaultActionQueues: boolean;
|
||||
isAgentRunning: boolean;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
|
||||
if (input.canPressLoadingButton) return input.t("composer.input.interruptAgent");
|
||||
if (input.defaultActionQueues) return input.t("composer.input.queueMessage");
|
||||
if (input.isAgentRunning) return input.t("composer.input.sendAndInterrupt");
|
||||
return input.t("composer.input.sendMessage");
|
||||
}
|
||||
|
||||
export function resolveVoiceAccessibilityLabel(input: {
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
isMuted: boolean;
|
||||
isDictating: boolean;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
if (input.isRealtimeVoiceForCurrentAgent) {
|
||||
return input.isMuted
|
||||
? input.t("composer.voice.unmuteVoiceMode")
|
||||
: input.t("composer.voice.muteVoiceMode");
|
||||
}
|
||||
if (input.isDictating) return input.t("composer.voice.stopDictation");
|
||||
return input.t("composer.voice.startDictation");
|
||||
}
|
||||
|
||||
export function resolveVoiceTooltipText(input: {
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
isMuted: boolean;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
if (input.isRealtimeVoiceForCurrentAgent) {
|
||||
return input.isMuted
|
||||
? input.t("composer.voice.unmuteVoice")
|
||||
: input.t("composer.voice.muteVoice");
|
||||
}
|
||||
return input.t("composer.voice.dictation");
|
||||
}
|
||||
|
||||
export function resolveSendTooltipLabel(input: {
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
defaultActionQueues: boolean;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
|
||||
return input.defaultActionQueues
|
||||
? input.t("composer.input.queue")
|
||||
: input.t("composer.input.send");
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
|
||||
|
||||
export interface AgentInputSubmitActionInput<TAttachment> {
|
||||
@@ -17,6 +19,7 @@ export interface AgentInputSubmitActionInput<TAttachment> {
|
||||
setSendError: (message: string | null) => void;
|
||||
setIsProcessing: (isProcessing: boolean) => void;
|
||||
onSubmitError?: (error: unknown) => void;
|
||||
failedToSendMessage?: string;
|
||||
}
|
||||
|
||||
export async function submitAgentInput<TAttachment>(
|
||||
@@ -67,7 +70,11 @@ export async function submitAgentInput<TAttachment>(
|
||||
input.setUserInput(trimmedMessage);
|
||||
input.setAttachments(attachments);
|
||||
}
|
||||
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
|
||||
input.setSendError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: (input.failedToSendMessage ?? i18n.t("composer.errors.failedToSend")),
|
||||
);
|
||||
input.setIsProcessing(false);
|
||||
return "failed";
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRef, ReactNode, useCallback, useEffect } from "react";
|
||||
import { Buffer } from "buffer";
|
||||
import { AppState } from "react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
@@ -440,6 +441,7 @@ export function SessionProvider(props: SessionProviderProps) {
|
||||
}
|
||||
|
||||
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
|
||||
const { t } = useTranslation();
|
||||
const voiceRuntime = useVoiceRuntimeOptional();
|
||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -908,25 +910,25 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
serverId,
|
||||
setVoiceMode: async (enabled, agentId) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
throw new Error(t("common.errors.daemonUnavailable"));
|
||||
}
|
||||
await client.setVoiceMode(enabled, agentId);
|
||||
},
|
||||
sendVoiceAudioChunk: async (audioData, mimeType) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
throw new Error(t("common.errors.daemonUnavailable"));
|
||||
}
|
||||
await client.sendVoiceAudioChunk(audioData, mimeType);
|
||||
},
|
||||
audioPlayed: async (chunkId) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
throw new Error(t("common.errors.daemonUnavailable"));
|
||||
}
|
||||
await client.audioPlayed(chunkId);
|
||||
},
|
||||
abortRequest: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
throw new Error(t("common.errors.daemonUnavailable"));
|
||||
}
|
||||
await client.abortRequest();
|
||||
},
|
||||
@@ -935,7 +937,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
},
|
||||
});
|
||||
return () => unregister?.();
|
||||
}, [client, serverId, setIsPlayingAudio, voiceRuntime]);
|
||||
}, [client, serverId, setIsPlayingAudio, t, voiceRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
voiceRuntime?.updateSessionConnection(serverId, isConnected);
|
||||
|
||||
@@ -10,6 +10,12 @@ export interface DesktopPermissionRowProps {
|
||||
title: string;
|
||||
status: DesktopPermissionStatus | null;
|
||||
isRequesting: boolean;
|
||||
labels: {
|
||||
granted: string;
|
||||
request: string;
|
||||
requesting: string;
|
||||
busyExtraAction: (label: string) => string;
|
||||
};
|
||||
showBorder?: boolean;
|
||||
onRequest: () => void;
|
||||
extraActionLabel?: string;
|
||||
@@ -22,6 +28,7 @@ export function DesktopPermissionRow({
|
||||
title,
|
||||
status,
|
||||
isRequesting,
|
||||
labels,
|
||||
showBorder,
|
||||
onRequest,
|
||||
extraActionLabel,
|
||||
@@ -54,7 +61,7 @@ export function DesktopPermissionRow({
|
||||
<View style={styles.permissionGrantedActions}>
|
||||
<View style={styles.permissionStatusPill}>
|
||||
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.permissionStatusText}>Granted</Text>
|
||||
<Text style={styles.permissionStatusText}>{labels.granted}</Text>
|
||||
</View>
|
||||
{extraActionLabel && onExtraAction ? (
|
||||
<Button
|
||||
@@ -63,13 +70,13 @@ export function DesktopPermissionRow({
|
||||
onPress={onExtraAction}
|
||||
disabled={isExtraActionDisabled || isExtraActionBusy}
|
||||
>
|
||||
{isExtraActionBusy ? `${extraActionLabel}...` : extraActionLabel}
|
||||
{isExtraActionBusy ? labels.busyExtraAction(extraActionLabel) : extraActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
|
||||
{isRequesting ? "Requesting..." : "Request"}
|
||||
{isRequesting ? labels.requesting : labels.request}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowDetail ? (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
@@ -9,6 +10,7 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
|
||||
export function DesktopPermissionsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
isDesktopApp,
|
||||
@@ -59,12 +61,22 @@ export function DesktopPermissionsSection() {
|
||||
leftIcon={refreshIcon}
|
||||
onPress={handleRefreshPress}
|
||||
disabled={isBusy}
|
||||
accessibilityLabel="Refresh desktop permissions"
|
||||
accessibilityLabel={t("settings.permissions.refreshAccessibility")}
|
||||
>
|
||||
{isRefreshing ? "Refreshing..." : "Refresh"}
|
||||
{isRefreshing ? t("settings.permissions.refreshing") : t("settings.permissions.refresh")}
|
||||
</Button>
|
||||
),
|
||||
[refreshIcon, handleRefreshPress, isBusy, isRefreshing],
|
||||
[refreshIcon, handleRefreshPress, isBusy, isRefreshing, t],
|
||||
);
|
||||
|
||||
const permissionLabels = useMemo(
|
||||
() => ({
|
||||
granted: t("settings.permissions.actions.granted"),
|
||||
request: t("settings.permissions.actions.request"),
|
||||
requesting: t("settings.permissions.actions.requesting"),
|
||||
busyExtraAction: (label: string) => t("settings.permissions.actions.busySuffix", { label }),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!isDesktopApp) {
|
||||
@@ -72,25 +84,27 @@ export function DesktopPermissionsSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title="Permissions" trailing={refreshButton}>
|
||||
<SettingsSection title={t("settings.permissions.title")} trailing={refreshButton}>
|
||||
<View style={settingsStyles.card}>
|
||||
<DesktopPermissionRow
|
||||
title="Notifications"
|
||||
title={t("settings.permissions.notifications")}
|
||||
status={snapshot?.notifications ?? null}
|
||||
isRequesting={requestingPermission === "notifications"}
|
||||
onRequest={handleRequestNotifications}
|
||||
extraActionLabel="Test"
|
||||
labels={permissionLabels}
|
||||
extraActionLabel={t("settings.permissions.test")}
|
||||
isExtraActionBusy={isSendingTestNotification}
|
||||
isExtraActionDisabled={!notificationsGranted || isBusy}
|
||||
onExtraAction={handleSendTestNotification}
|
||||
/>
|
||||
{testNotificationError ? <Text style={errorTextStyle}>{testNotificationError}</Text> : null}
|
||||
<DesktopPermissionRow
|
||||
title="Microphone"
|
||||
title={t("settings.permissions.microphone")}
|
||||
showBorder
|
||||
status={snapshot?.microphone ?? null}
|
||||
isRequesting={requestingPermission === "microphone"}
|
||||
onRequest={handleRequestMicrophone}
|
||||
labels={permissionLabels}
|
||||
/>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { type ReactElement, useCallback, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
@@ -41,6 +42,7 @@ function useKeepRunningAfterQuitToggle(args: {
|
||||
}
|
||||
|
||||
function useDaemonCliStatusModal() {
|
||||
const { t } = useTranslation();
|
||||
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
||||
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
||||
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
||||
@@ -52,12 +54,12 @@ function useDaemonCliStatusModal() {
|
||||
setIsCliStatusModalOpen(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setCliStatusOutput(`Failed to fetch daemon status: ${message}`);
|
||||
setCliStatusOutput(t("desktop.daemon.fullStatus.fetchFailed", { message }));
|
||||
setIsCliStatusModalOpen(true);
|
||||
} finally {
|
||||
setIsLoadingCliStatus(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleCopyCliStatus = useCallback(() => {
|
||||
if (!cliStatusOutput) {
|
||||
@@ -65,13 +67,13 @@ function useDaemonCliStatusModal() {
|
||||
}
|
||||
void Clipboard.setStringAsync(cliStatusOutput)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Status copied to clipboard.");
|
||||
Alert.alert(t("common.states.copied"), t("desktop.daemon.fullStatus.copied"));
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy daemon status", error);
|
||||
});
|
||||
}, [cliStatusOutput]);
|
||||
}, [cliStatusOutput, t]);
|
||||
|
||||
const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []);
|
||||
|
||||
@@ -86,6 +88,7 @@ function useDaemonCliStatusModal() {
|
||||
}
|
||||
|
||||
function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) {
|
||||
const { t } = useTranslation();
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||
|
||||
const handleCopyLogPath = useCallback(() => {
|
||||
@@ -96,14 +99,14 @@ function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) {
|
||||
|
||||
void Clipboard.setStringAsync(logPath)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Log path copied.");
|
||||
Alert.alert(t("common.states.copied"), t("desktop.daemon.logs.copied"));
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy log path", error);
|
||||
Alert.alert("Error", "Unable to copy log path.");
|
||||
Alert.alert(t("common.errors.error"), t("desktop.daemon.logs.copyFailed"));
|
||||
});
|
||||
}, [daemonLogs?.logPath]);
|
||||
}, [daemonLogs?.logPath, t]);
|
||||
|
||||
const handleOpenLogs = useCallback(() => {
|
||||
if (!daemonLogs) {
|
||||
@@ -124,18 +127,23 @@ interface DaemonLogsModalProps {
|
||||
}
|
||||
|
||||
function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("desktop.daemon.logs.modalTitle") }), [t]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={DAEMON_LOGS_HEADER}
|
||||
header={header}
|
||||
testID="managed-daemon-logs-dialog"
|
||||
snapPoints={LOGS_MODAL_SNAP_POINTS}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable"}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{daemonLogs?.logPath ?? t("desktop.daemon.logs.unavailable")}
|
||||
</Text>
|
||||
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
|
||||
{daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"}
|
||||
{daemonLogs?.contents?.length ? daemonLogs.contents : t("desktop.daemon.logs.empty")}
|
||||
</Text>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
@@ -155,11 +163,17 @@ function DaemonCliStatusModal({
|
||||
cliStatusOutput,
|
||||
onCopy,
|
||||
}: DaemonCliStatusModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("desktop.daemon.fullStatus.modalTitle") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={DAEMON_STATUS_HEADER}
|
||||
header={header}
|
||||
testID="daemon-cli-status-dialog"
|
||||
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
|
||||
>
|
||||
@@ -169,10 +183,10 @@ function DaemonCliStatusModal({
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={onClose}>
|
||||
Close
|
||||
{t("common.actions.close")}
|
||||
</Button>
|
||||
<Button size="sm" onPress={onCopy}>
|
||||
Copy
|
||||
{t("common.actions.copy")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -200,6 +214,7 @@ interface DaemonInfoCardProps {
|
||||
}
|
||||
|
||||
function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
daemonStatusStateText,
|
||||
daemonStatusDetailText,
|
||||
@@ -223,8 +238,8 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.status.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.status.builtInOnly")}</Text>
|
||||
</View>
|
||||
<View style={styles.statusValueGroup}>
|
||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||
@@ -233,39 +248,39 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Manage built-in daemon</Text>
|
||||
<Text style={settingsStyles.rowHint}>Let Paseo start and stop the built-in daemon</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.management.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.management.hint")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={!isDaemonManagementPaused}
|
||||
onValueChange={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
accessibilityLabel="Manage built-in daemon"
|
||||
accessibilityLabel={t("desktop.daemon.management.title")}
|
||||
/>
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Keep daemon running after quit</Text>
|
||||
<Text style={settingsStyles.rowHint}>Daemon keeps running when you quit Paseo</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.keepRunning.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.keepRunning.hint")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={keepRunningAfterQuit}
|
||||
onValueChange={handleToggleKeepRunningAfterQuit}
|
||||
disabled={isUpdatingKeepRunningAfterQuit}
|
||||
accessibilityLabel="Keep daemon running after quit"
|
||||
accessibilityLabel={t("desktop.daemon.keepRunning.title")}
|
||||
/>
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.logs.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{daemonLogs?.logPath ?? "Log path unavailable"}
|
||||
{daemonLogs?.logPath ?? t("desktop.daemon.logs.unavailable")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{daemonLogs?.logPath ? (
|
||||
<Button variant="outline" size="sm" leftIcon={copyIcon} onPress={handleCopyLogPath}>
|
||||
Copy path
|
||||
{t("desktop.daemon.logs.copyPath")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -275,16 +290,14 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
onPress={handleOpenLogs}
|
||||
disabled={!daemonLogs}
|
||||
>
|
||||
Open logs
|
||||
{t("desktop.daemon.logs.open")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Runs `paseo daemon status` and shows the output
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.fullStatus.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.fullStatus.hint")}</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -293,7 +306,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
onPress={handleRunCliStatus}
|
||||
disabled={isLoadingCliStatus}
|
||||
>
|
||||
{isLoadingCliStatus ? "Loading..." : "View status"}
|
||||
{isLoadingCliStatus ? t("common.states.loading") : t("desktop.daemon.fullStatus.view")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -301,6 +314,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
}
|
||||
|
||||
export function LocalDaemonSection() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const appVersion = resolveAppVersion();
|
||||
@@ -318,8 +332,13 @@ export function LocalDaemonSection() {
|
||||
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
|
||||
const daemonStatusStateText =
|
||||
statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running");
|
||||
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`;
|
||||
statusError ??
|
||||
(daemonStatus?.status === "running"
|
||||
? t("desktop.daemon.status.running")
|
||||
: t("desktop.daemon.status.notRunning"));
|
||||
const daemonStatusDetailText = t("desktop.daemon.status.pid", {
|
||||
pid: daemonStatus?.pid ? daemonStatus.pid : "—",
|
||||
});
|
||||
const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon;
|
||||
|
||||
const { isUpdating: isUpdatingDaemonManagement, toggle: handleToggleDaemonManagement } =
|
||||
@@ -382,12 +401,12 @@ export function LocalDaemonSection() {
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={handleOpenAdvancedSettings}
|
||||
accessibilityLabel="Open advanced daemon settings"
|
||||
accessibilityLabel={t("desktop.daemon.openAdvancedSettings")}
|
||||
>
|
||||
Advanced settings
|
||||
{t("desktop.daemon.advancedSettings")}
|
||||
</Button>
|
||||
),
|
||||
[advancedSettingsIcon, handleOpenAdvancedSettings],
|
||||
[advancedSettingsIcon, handleOpenAdvancedSettings, t],
|
||||
);
|
||||
|
||||
if (!showSection) {
|
||||
@@ -396,7 +415,7 @@ export function LocalDaemonSection() {
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Daemon"
|
||||
title={t("desktop.daemon.title")}
|
||||
trailing={advancedSettingsButton}
|
||||
testID="host-page-daemon-lifecycle-card"
|
||||
>
|
||||
@@ -427,11 +446,7 @@ export function LocalDaemonSection() {
|
||||
|
||||
{daemonVersionMismatch ? (
|
||||
<View style={styles.warningCard}>
|
||||
<Text style={styles.warningText}>
|
||||
{
|
||||
"App and daemon versions don't match. Update both to the same version for the best experience."
|
||||
}
|
||||
</Text>
|
||||
<Text style={styles.warningText}>{t("desktop.daemon.versionMismatch")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
@@ -513,5 +528,3 @@ const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard];
|
||||
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||
const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"];
|
||||
const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"];
|
||||
const DAEMON_LOGS_HEADER: SheetHeader = { title: "Daemon logs" };
|
||||
const DAEMON_STATUS_HEADER: SheetHeader = { title: "Daemon status" };
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -8,16 +10,34 @@ import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { shouldUseDesktopDaemon, type SkillsStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
import {
|
||||
shouldUseDesktopDaemon,
|
||||
type SkillOp,
|
||||
type SkillsStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
|
||||
|
||||
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||
const UNINSTALL_MESSAGE =
|
||||
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
|
||||
|
||||
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
|
||||
const OP_KIND_LABEL_KEY: Record<SkillOp["kind"], string> = {
|
||||
add: "settings.integrations.operations.add",
|
||||
update: "settings.integrations.operations.update",
|
||||
delete: "settings.integrations.operations.delete",
|
||||
};
|
||||
|
||||
function formatUpdateMessage(ops: readonly SkillOp[], t: TFunction): string {
|
||||
const sorted = [...ops].sort((a, b) => {
|
||||
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
|
||||
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted.map((op) => `${t(OP_KIND_LABEL_KEY[op.kind])} ${op.name}`).join("\n");
|
||||
}
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const {
|
||||
@@ -56,20 +76,30 @@ export function IntegrationsSection() {
|
||||
|
||||
const handleUpdateSkills = useCallback(async () => {
|
||||
if (isSkillsWorking) return;
|
||||
const ops = skillsStatus?.ops ?? [];
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("settings.integrations.skills.updateTitle"),
|
||||
message:
|
||||
ops.length > 0
|
||||
? formatUpdateMessage(ops, t)
|
||||
: t("settings.integrations.skills.updateFallback"),
|
||||
confirmLabel: t("settings.integrations.actions.update"),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await updateSkills();
|
||||
}, [isSkillsWorking, updateSkills]);
|
||||
}, [isSkillsWorking, skillsStatus, t, updateSkills]);
|
||||
|
||||
const handleUninstallSkills = useCallback(async () => {
|
||||
if (isSkillsWorking) return;
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Uninstall Paseo skills?",
|
||||
message: UNINSTALL_MESSAGE,
|
||||
confirmLabel: "Uninstall",
|
||||
title: t("settings.integrations.skills.uninstallTitle"),
|
||||
message: t("settings.integrations.skills.uninstallMessage"),
|
||||
confirmLabel: t("settings.integrations.actions.uninstall"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await uninstallSkills();
|
||||
}, [isSkillsWorking, uninstallSkills]);
|
||||
}, [isSkillsWorking, t, uninstallSkills]);
|
||||
|
||||
const handleOpenCliDocs = useCallback(() => {
|
||||
void openExternalUrl(CLI_DOCS_URL);
|
||||
@@ -94,9 +124,9 @@ export function IntegrationsSection() {
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={handleOpenCliDocs}
|
||||
accessibilityLabel="Open CLI documentation"
|
||||
accessibilityLabel={t("settings.integrations.docs.openCli")}
|
||||
>
|
||||
CLI docs
|
||||
{t("settings.integrations.docs.cli")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -105,13 +135,13 @@ export function IntegrationsSection() {
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={handleOpenSkillsDocs}
|
||||
accessibilityLabel="Open skills documentation"
|
||||
accessibilityLabel={t("settings.integrations.docs.openSkills")}
|
||||
>
|
||||
Skills docs
|
||||
{t("settings.integrations.docs.skills")}
|
||||
</Button>
|
||||
</View>
|
||||
),
|
||||
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs],
|
||||
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs, t],
|
||||
);
|
||||
|
||||
if (!showSection) {
|
||||
@@ -121,20 +151,24 @@ export function IntegrationsSection() {
|
||||
const skillsState = skillsStatus?.state ?? null;
|
||||
|
||||
return (
|
||||
<SettingsSection title="Integrations" trailing={trailing}>
|
||||
<SettingsSection title={t("settings.integrations.title")} trailing={trailing}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>Command line</Text>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.integrations.commandLine.title")}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>Control and script agents from your terminal</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.integrations.commandLine.description")}
|
||||
</Text>
|
||||
</View>
|
||||
{cliStatus?.installed ? (
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
@@ -143,7 +177,9 @@ export function IntegrationsSection() {
|
||||
onPress={handleInstallCli}
|
||||
disabled={isInstallingCli}
|
||||
>
|
||||
{isInstallingCli ? "Installing..." : "Install"}
|
||||
{isInstallingCli
|
||||
? t("settings.integrations.actions.installing")
|
||||
: t("settings.integrations.actions.install")}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
@@ -151,12 +187,12 @@ export function IntegrationsSection() {
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.integrations.skills.title")}</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{skillsState === "drift"
|
||||
? "Update available"
|
||||
: "Teach your agents to orchestrate through the CLI"}
|
||||
? t("settings.integrations.skills.updateAvailable")
|
||||
: t("settings.integrations.skills.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<SkillsActions
|
||||
@@ -181,6 +217,7 @@ interface SkillsActionsProps {
|
||||
}
|
||||
|
||||
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (state === "up-to-date") {
|
||||
@@ -188,10 +225,10 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
|
||||
<View style={styles.actionsRow}>
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||
Uninstall
|
||||
{t("settings.integrations.actions.uninstall")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -201,10 +238,12 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
|
||||
return (
|
||||
<View style={styles.actionsRow}>
|
||||
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
|
||||
{isWorking ? "Working..." : "Update"}
|
||||
{isWorking
|
||||
? t("settings.integrations.actions.working")
|
||||
: t("settings.integrations.actions.update")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||
Uninstall
|
||||
{t("settings.integrations.actions.uninstall")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -212,7 +251,9 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
|
||||
{isWorking ? "Installing..." : "Install"}
|
||||
{isWorking
|
||||
? t("settings.integrations.actions.installing")
|
||||
: t("settings.integrations.actions.install")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
|
||||
|
||||
@@ -8,12 +10,17 @@ export interface PairDeviceModalProps {
|
||||
}
|
||||
|
||||
const SNAP_POINTS: string[] = ["82%", "94%"];
|
||||
const PAIR_DEVICE_HEADER: SheetHeader = { title: "Pair a device" };
|
||||
|
||||
export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("settings.integrations.pairDevices.rowTitle") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={PAIR_DEVICE_HEADER}
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={SNAP_POINTS}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, Image, Text, TextInput, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as QRCode from "qrcode";
|
||||
@@ -21,18 +22,21 @@ function resolvePairingViewState(args: {
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
data: { url?: string | null; relayEnabled?: boolean } | undefined;
|
||||
labels: {
|
||||
failedToLoadOffer: string;
|
||||
relayDisabled: string;
|
||||
unavailable: string;
|
||||
};
|
||||
}): PairingViewState {
|
||||
if (args.isPending) return { tag: "loading" };
|
||||
if (args.isError) {
|
||||
const message =
|
||||
args.error instanceof Error ? args.error.message : "Failed to load pairing offer.";
|
||||
args.error instanceof Error ? args.error.message : args.labels.failedToLoadOffer;
|
||||
return { tag: "error", message };
|
||||
}
|
||||
if (!args.data?.url) {
|
||||
const message =
|
||||
args.data?.relayEnabled === false
|
||||
? "Relay is not enabled. Enable relay to pair a device."
|
||||
: "Pairing offer unavailable.";
|
||||
args.data?.relayEnabled === false ? args.labels.relayDisabled : args.labels.unavailable;
|
||||
return { tag: "unavailable", message };
|
||||
}
|
||||
return { tag: "ready", url: args.data.url };
|
||||
@@ -40,6 +44,7 @@ function resolvePairingViewState(args: {
|
||||
|
||||
export function PairDeviceSection() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -96,6 +101,17 @@ export function PairDeviceSection() {
|
||||
),
|
||||
[copied, theme.iconSize.sm, theme.colors.accent, theme.colors.foreground],
|
||||
);
|
||||
const bodyLabels = useMemo(
|
||||
() => ({
|
||||
loadingOffer: t("pairing.device.loadingOffer"),
|
||||
hint: t("pairing.device.hint"),
|
||||
qrUnavailable: t("pairing.device.qrUnavailable"),
|
||||
retry: t("pairing.device.retry"),
|
||||
copy: t("pairing.device.copy"),
|
||||
copied: t("pairing.device.copied"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!showSection) return null;
|
||||
|
||||
@@ -104,6 +120,11 @@ export function PairDeviceSection() {
|
||||
isError: pairingQuery.isError,
|
||||
error: pairingQuery.error,
|
||||
data: pairingQuery.data,
|
||||
labels: {
|
||||
failedToLoadOffer: t("pairing.device.failedToLoadOffer"),
|
||||
relayDisabled: t("pairing.device.relayDisabled"),
|
||||
unavailable: t("pairing.device.unavailable"),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -119,6 +140,7 @@ export function PairDeviceSection() {
|
||||
copied={copied}
|
||||
handleRefetch={handleRefetch}
|
||||
handleCopyPress={handleCopyPress}
|
||||
labels={bodyLabels}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -135,6 +157,14 @@ interface PairDeviceBodyProps {
|
||||
copied: boolean;
|
||||
handleRefetch: () => void;
|
||||
handleCopyPress: () => void;
|
||||
labels: {
|
||||
loadingOffer: string;
|
||||
hint: string;
|
||||
qrUnavailable: string;
|
||||
retry: string;
|
||||
copy: string;
|
||||
copied: string;
|
||||
};
|
||||
}
|
||||
|
||||
function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
@@ -148,13 +178,14 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
copied,
|
||||
handleRefetch,
|
||||
handleCopyPress,
|
||||
labels,
|
||||
} = props;
|
||||
|
||||
if (viewState.tag === "loading") {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.hint}>Loading pairing offer…</Text>
|
||||
<Text style={styles.hint}>{labels.loadingOffer}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -164,7 +195,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.hint}>{viewState.message}</Text>
|
||||
<Button variant="outline" size="sm" leftIcon={retryIcon} onPress={handleRefetch}>
|
||||
Retry
|
||||
{labels.retry}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -172,11 +203,13 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
|
||||
return (
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.hint}>
|
||||
Scan this QR code with Paseo on your phone, or copy the link below.
|
||||
</Text>
|
||||
<Text style={styles.hint}>{labels.hint}</Text>
|
||||
<View style={styles.qrContainer}>
|
||||
<PairDeviceQrContent qrImageSource={qrImageSource} qrQuery={qrQuery} />
|
||||
<PairDeviceQrContent
|
||||
qrImageSource={qrImageSource}
|
||||
qrQuery={qrQuery}
|
||||
unavailableLabel={labels.qrUnavailable}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.linkRow}>
|
||||
<View style={styles.inputWrapper}>
|
||||
@@ -189,7 +222,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
/>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" leftIcon={copyButtonIcon} onPress={handleCopyPress}>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
{copied ? labels.copied : labels.copy}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -199,12 +232,13 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
function PairDeviceQrContent(props: {
|
||||
qrImageSource: { uri: string } | null;
|
||||
qrQuery: { isError: boolean };
|
||||
unavailableLabel: string;
|
||||
}) {
|
||||
if (props.qrImageSource) {
|
||||
return <Image source={props.qrImageSource} style={styles.qrImage} resizeMode="contain" />;
|
||||
}
|
||||
if (props.qrQuery.isError) {
|
||||
return <Text style={styles.hint}>QR code unavailable.</Text>;
|
||||
return <Text style={styles.hint}>{props.unavailableLabel}</Text>;
|
||||
}
|
||||
return <ActivityIndicator size="small" />;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export class DaemonConnectionRegistrationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@@ -34,19 +36,18 @@ export function getDaemonManagementErrorPresentation(
|
||||
|
||||
if (presentationError instanceof DaemonConnectionRegistrationError) {
|
||||
return {
|
||||
message:
|
||||
"Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.",
|
||||
message: i18n.t("desktop.daemon.management.registrationFailed"),
|
||||
refreshStatus: true,
|
||||
};
|
||||
}
|
||||
if (wasManagingDaemon) {
|
||||
return {
|
||||
message: "Built-in daemon management was paused, but Paseo could not stop the daemon.",
|
||||
message: i18n.t("desktop.daemon.management.pausedStopFailed"),
|
||||
refreshStatus: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: "Unable to update built-in daemon management.",
|
||||
message: i18n.t("desktop.daemon.management.updateFailed"),
|
||||
refreshStatus: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type DesktopDaemonStatus,
|
||||
startDesktopDaemon,
|
||||
@@ -38,6 +39,7 @@ interface UseBuiltInDaemonManagementResult {
|
||||
export function useBuiltInDaemonManagement(
|
||||
input: UseBuiltInDaemonManagementInput,
|
||||
): UseBuiltInDaemonManagementResult {
|
||||
const { t } = useTranslation();
|
||||
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
|
||||
const reportError = useDesktopIpcErrorReporter();
|
||||
const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation<
|
||||
@@ -50,11 +52,10 @@ export function useBuiltInDaemonManagement(
|
||||
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
|
||||
confirm: () =>
|
||||
confirmDialog({
|
||||
title: "Pause built-in daemon",
|
||||
message:
|
||||
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
|
||||
confirmLabel: "Pause and stop",
|
||||
cancelLabel: "Cancel",
|
||||
title: t("desktop.daemon.management.pauseTitle"),
|
||||
message: t("desktop.daemon.management.pauseMessage"),
|
||||
confirmLabel: t("desktop.daemon.management.pauseAndStop"),
|
||||
cancelLabel: t("common.actions.cancel"),
|
||||
destructive: true,
|
||||
}),
|
||||
persistSettings: (next) => updateSettings(next) as Promise<void>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getDesktopDaemonLogs,
|
||||
getDesktopDaemonStatus,
|
||||
@@ -17,6 +18,7 @@ interface DaemonStatusData {
|
||||
}
|
||||
|
||||
export function useDaemonStatus() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
|
||||
@@ -33,7 +35,7 @@ export function useDaemonStatus() {
|
||||
});
|
||||
useDesktopIpcQueryErrorToast({
|
||||
error: query.error,
|
||||
message: "Unable to load desktop daemon status.",
|
||||
message: t("desktop.daemon.loadFailed"),
|
||||
logLabel: "[DesktopDaemon] Failed to load daemon status",
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { useCliInstall, useSkillsStatus } from "./use-install-status";
|
||||
|
||||
const toast = vi.hoisted(() => ({
|
||||
@@ -54,6 +55,7 @@ describe("useCliInstall", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
void i18n.changeLanguage("en");
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -89,6 +91,28 @@ describe("useCliInstall", () => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Unable to install the Paseo CLI.");
|
||||
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install CLI", error);
|
||||
});
|
||||
|
||||
it("uses the active language for CLI install errors", async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
const error = new Error("Missing IPC handler");
|
||||
desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: false });
|
||||
desktopDaemon.installCli.mockRejectedValue(error);
|
||||
const { result } = renderDesktopHook(() => useCliInstall());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ installed: false });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.install();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe(error);
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("无法安装 Paseo CLI。");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSkillsStatus", () => {
|
||||
@@ -97,6 +121,7 @@ describe("useSkillsStatus", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
void i18n.changeLanguage("en");
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getCliInstallStatus,
|
||||
getSkillsStatus,
|
||||
@@ -29,6 +30,7 @@ interface DesktopInstallHookResult {
|
||||
}
|
||||
|
||||
export function useCliInstall(): DesktopInstallHookResult {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const reportError = useDesktopIpcErrorReporter();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
@@ -42,7 +44,7 @@ export function useCliInstall(): DesktopInstallHookResult {
|
||||
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
|
||||
useDesktopIpcQueryErrorToast({
|
||||
error: statusQuery.error,
|
||||
message: "Unable to check CLI install status.",
|
||||
message: t("desktop.integrations.cli.statusFailed"),
|
||||
logLabel: "[Integrations] Failed to load CLI status",
|
||||
});
|
||||
|
||||
@@ -51,7 +53,7 @@ export function useCliInstall(): DesktopInstallHookResult {
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to install the Paseo CLI.",
|
||||
message: t("desktop.integrations.cli.installFailed"),
|
||||
logLabel: "[Integrations] Failed to install CLI",
|
||||
});
|
||||
},
|
||||
@@ -88,6 +90,7 @@ export interface SkillsStatusHookResult {
|
||||
}
|
||||
|
||||
export function useSkillsStatus(): SkillsStatusHookResult {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const reportError = useDesktopIpcErrorReporter();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
@@ -101,7 +104,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
|
||||
const { data: status, error: statusError, isLoading, refetch } = statusQuery;
|
||||
useDesktopIpcQueryErrorToast({
|
||||
error: statusQuery.error,
|
||||
message: "Unable to check orchestration skills status.",
|
||||
message: t("desktop.integrations.skills.statusFailed"),
|
||||
logLabel: "[Integrations] Failed to load skills status",
|
||||
});
|
||||
|
||||
@@ -117,7 +120,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to install orchestration skills.",
|
||||
message: t("desktop.integrations.skills.installFailed"),
|
||||
logLabel: "[Integrations] Failed to install skills",
|
||||
});
|
||||
},
|
||||
@@ -129,7 +132,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to update orchestration skills.",
|
||||
message: t("desktop.integrations.skills.updateFailed"),
|
||||
logLabel: "[Integrations] Failed to update skills",
|
||||
});
|
||||
},
|
||||
@@ -141,7 +144,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to uninstall orchestration skills.",
|
||||
message: t("desktop.integrations.skills.uninstallFailed"),
|
||||
logLabel: "[Integrations] Failed to uninstall skills",
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { DesktopHostBridge } from "@/desktop/host";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
createDesktopPermissions,
|
||||
type DesktopPermissionEnvironment,
|
||||
@@ -180,4 +181,28 @@ describe("desktop-permissions", () => {
|
||||
|
||||
expect(result.state).toBe("denied");
|
||||
});
|
||||
|
||||
it("uses the active app language for local status details", async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
try {
|
||||
const permissions = createDesktopPermissions(
|
||||
fakeEnvironment({
|
||||
notification: { permission: "granted" },
|
||||
navigator: {
|
||||
permissions: {
|
||||
query: vi.fn(async () => ({ state: "prompt" })),
|
||||
},
|
||||
mediaDevices: { getUserMedia: vi.fn() },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshot = await permissions.getDesktopPermissionSnapshot();
|
||||
|
||||
expect(snapshot.notifications.detail).toBe("系统已允许通知。");
|
||||
expect(snapshot.microphone.detail).toBe("麦克风权限尚未授予。");
|
||||
} finally {
|
||||
await i18n.changeLanguage("en");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type DesktopHostBridge, getDesktopHost } from "@/desktop/host";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export type DesktopPermissionKind = "notifications" | "microphone";
|
||||
|
||||
@@ -97,24 +98,26 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
|
||||
if (permission === "granted") {
|
||||
return status({
|
||||
state: "granted",
|
||||
detail: "Notifications are allowed by the OS.",
|
||||
detail: i18n.t("desktop.permissions.notifications.allowed"),
|
||||
});
|
||||
}
|
||||
if (permission === "denied") {
|
||||
return status({
|
||||
state: "denied",
|
||||
detail: "Notifications are denied in system settings.",
|
||||
detail: i18n.t("desktop.permissions.notifications.denied"),
|
||||
});
|
||||
}
|
||||
if (permission === "default") {
|
||||
return status({
|
||||
state: "prompt",
|
||||
detail: "Notifications have not been granted yet.",
|
||||
detail: i18n.t("desktop.permissions.notifications.notGranted"),
|
||||
});
|
||||
}
|
||||
return status({
|
||||
state: "unknown",
|
||||
detail: `Unexpected notification permission state: ${permission}`,
|
||||
detail: i18n.t("desktop.permissions.notifications.unexpectedState", {
|
||||
state: permission,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,7 +130,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (!env.isWeb) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop notification status is only available on web runtime.",
|
||||
detail: i18n.t("desktop.permissions.notifications.webOnly"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,8 +141,8 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
return status({
|
||||
state: supported ? "granted" : "unavailable",
|
||||
detail: supported
|
||||
? "Desktop notifications are supported."
|
||||
: "Desktop notifications are not supported on this platform.",
|
||||
? i18n.t("desktop.permissions.notifications.supported")
|
||||
: i18n.t("desktop.permissions.notifications.unsupported"),
|
||||
});
|
||||
} catch {
|
||||
// Fall through to web API check
|
||||
@@ -153,7 +156,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Web Notification API is unavailable in this environment.",
|
||||
detail: i18n.t("desktop.permissions.notifications.apiUnavailable"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -161,7 +164,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (!env.isWeb) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop microphone status is only available on web runtime.",
|
||||
detail: i18n.t("desktop.permissions.microphone.webOnly"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,7 +172,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (!webNavigator) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Navigator is unavailable in this environment.",
|
||||
detail: i18n.t("desktop.permissions.microphone.navigatorUnavailable"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -180,36 +183,39 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (result?.state === "granted") {
|
||||
return status({
|
||||
state: "granted",
|
||||
detail: "Microphone access is granted.",
|
||||
detail: i18n.t("desktop.permissions.microphone.granted"),
|
||||
});
|
||||
}
|
||||
if (result?.state === "denied") {
|
||||
return status({
|
||||
state: "denied",
|
||||
detail: "Microphone access is denied in system settings.",
|
||||
detail: i18n.t("desktop.permissions.microphone.denied"),
|
||||
});
|
||||
}
|
||||
if (result?.state === "prompt") {
|
||||
return status({
|
||||
state: "prompt",
|
||||
detail: "Microphone permission has not been granted yet.",
|
||||
detail: i18n.t("desktop.permissions.microphone.notGranted"),
|
||||
});
|
||||
}
|
||||
return status({
|
||||
state: "unknown",
|
||||
detail: `Unexpected microphone permission state: ${result?.state ?? "unknown"}`,
|
||||
detail: i18n.t("desktop.permissions.microphone.unexpectedState", {
|
||||
state: result?.state ?? "unknown",
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isPermissionsQueryRuntimeUnsupported(error)) {
|
||||
return status({
|
||||
state: "unknown",
|
||||
detail:
|
||||
"Microphone status API is unavailable in this runtime. Use Request to check access.",
|
||||
detail: i18n.t("desktop.permissions.microphone.statusApiUnavailable"),
|
||||
});
|
||||
}
|
||||
return status({
|
||||
state: "unknown",
|
||||
detail: `Failed to query microphone status: ${getErrorMessage(error)}`,
|
||||
detail: i18n.t("desktop.permissions.microphone.queryFailed", {
|
||||
message: getErrorMessage(error),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -217,13 +223,13 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Microphone capture is unavailable in this environment.",
|
||||
detail: i18n.t("desktop.permissions.microphone.captureUnavailable"),
|
||||
});
|
||||
}
|
||||
|
||||
return status({
|
||||
state: "unknown",
|
||||
detail: "Permission status API is unavailable. Use Request to check access.",
|
||||
detail: i18n.t("desktop.permissions.microphone.permissionApiUnavailable"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -231,7 +237,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (!env.isWeb) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop notification requests are only available on web runtime.",
|
||||
detail: i18n.t("desktop.permissions.notifications.requestsWebOnly"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -246,14 +252,16 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
} catch (error) {
|
||||
return status({
|
||||
state: "unknown",
|
||||
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
|
||||
detail: i18n.t("desktop.permissions.notifications.requestFailed", {
|
||||
message: getErrorMessage(error),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Web Notification API requestPermission() is unavailable.",
|
||||
detail: i18n.t("desktop.permissions.notifications.requestUnavailable"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,7 +269,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (!env.isWeb) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop microphone requests are only available on web runtime.",
|
||||
detail: i18n.t("desktop.permissions.microphone.requestsWebOnly"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -269,7 +277,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Microphone capture API is unavailable in this environment.",
|
||||
detail: i18n.t("desktop.permissions.microphone.captureApiUnavailable"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -287,18 +295,20 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (errorName === "NotAllowedError" || errorName === "PermissionDeniedError") {
|
||||
return status({
|
||||
state: "denied",
|
||||
detail: "Microphone permission was denied by the user or system.",
|
||||
detail: i18n.t("desktop.permissions.microphone.requestDenied"),
|
||||
});
|
||||
}
|
||||
if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "No microphone device was found.",
|
||||
detail: i18n.t("desktop.permissions.microphone.noDevice"),
|
||||
});
|
||||
}
|
||||
return status({
|
||||
state: "unknown",
|
||||
detail: `Failed to request microphone permission: ${getErrorMessage(error)}`,
|
||||
detail: i18n.t("desktop.permissions.microphone.requestFailed", {
|
||||
message: getErrorMessage(error),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getDesktopPermissionSnapshot,
|
||||
requestDesktopPermission,
|
||||
@@ -20,17 +21,8 @@ export interface UseDesktopPermissionsReturn {
|
||||
sendTestNotification: () => Promise<void>;
|
||||
}
|
||||
|
||||
const EMPTY_NOTIFICATION_STATUS = {
|
||||
state: "unknown" as const,
|
||||
detail: "Notification status has not been checked yet.",
|
||||
};
|
||||
|
||||
const EMPTY_MICROPHONE_STATUS = {
|
||||
state: "unknown" as const,
|
||||
detail: "Microphone status has not been checked yet.",
|
||||
};
|
||||
|
||||
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
const { t } = useTranslation();
|
||||
const isDesktopApp = shouldShowDesktopPermissionSection();
|
||||
const isMountedRef = useRef(true);
|
||||
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
|
||||
@@ -83,8 +75,14 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
setSnapshot((previous) => {
|
||||
const base: DesktopPermissionSnapshot = previous ?? {
|
||||
checkedAt: Date.now(),
|
||||
notifications: EMPTY_NOTIFICATION_STATUS,
|
||||
microphone: EMPTY_MICROPHONE_STATUS,
|
||||
notifications: {
|
||||
state: "unknown",
|
||||
detail: t("desktop.permissions.empty.notifications"),
|
||||
},
|
||||
microphone: {
|
||||
state: "unknown",
|
||||
detail: t("desktop.permissions.empty.microphone"),
|
||||
},
|
||||
};
|
||||
|
||||
if (kind === "notifications") {
|
||||
@@ -110,7 +108,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
await refreshPermissions();
|
||||
}
|
||||
},
|
||||
[isDesktopApp, refreshPermissions],
|
||||
[isDesktopApp, refreshPermissions, t],
|
||||
);
|
||||
|
||||
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
|
||||
@@ -124,22 +122,20 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
setTestNotificationError(null);
|
||||
try {
|
||||
const sent = await sendOsNotification({
|
||||
title: "Paseo notification test",
|
||||
body: "If you can see this, desktop notifications work.",
|
||||
title: t("desktop.permissions.testNotification.title"),
|
||||
body: t("desktop.permissions.testNotification.body"),
|
||||
});
|
||||
if (!sent) {
|
||||
setTestNotificationError(
|
||||
"Notification was not delivered. Check System Settings > Notifications.",
|
||||
);
|
||||
setTestNotificationError(t("desktop.permissions.testNotification.notDelivered"));
|
||||
}
|
||||
} catch {
|
||||
setTestNotificationError("Failed to send notification.");
|
||||
setTestNotificationError(t("desktop.permissions.testNotification.failed"));
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsSendingTestNotification(false);
|
||||
}
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
}, [isDesktopApp, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopApp) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useDesktopIpcQueryErrorToast,
|
||||
} from "@/desktop/hooks/desktop-ipc-error";
|
||||
import type { ReleaseChannel } from "@/hooks/use-settings";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
const DESKTOP_SETTINGS_QUERY_KEY = ["desktop-settings"] as const;
|
||||
|
||||
@@ -53,7 +54,7 @@ export function useDesktopSettings(): {
|
||||
});
|
||||
useDesktopIpcQueryErrorToast({
|
||||
error: loadError,
|
||||
message: "Unable to load desktop settings.",
|
||||
message: i18n.t("desktop.settings.loadFailed"),
|
||||
logLabel: "[DesktopSettings] Failed to load settings",
|
||||
});
|
||||
|
||||
@@ -83,7 +84,7 @@ export function useDesktopSettings(): {
|
||||
}
|
||||
reportError({
|
||||
error: saveError,
|
||||
message: "Unable to save desktop settings.",
|
||||
message: i18n.t("desktop.settings.saveFailed"),
|
||||
logLabel: "[DesktopSettings] Failed to save settings",
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
createDesktopAppUpdater,
|
||||
formatStatusText,
|
||||
@@ -292,4 +293,32 @@ describe("formatStatusText", () => {
|
||||
}),
|
||||
).toBe("Restart now");
|
||||
});
|
||||
|
||||
it("uses the active app language for local status wrappers", async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
try {
|
||||
expect(
|
||||
formatStatusText({
|
||||
status: "checking",
|
||||
availableUpdate: null,
|
||||
installMessage: null,
|
||||
lastCheckedAt: null,
|
||||
formatVersion,
|
||||
formatLastCheckedAt,
|
||||
}),
|
||||
).toBe("正在检查 app 更新...");
|
||||
expect(
|
||||
formatStatusText({
|
||||
status: "available",
|
||||
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
|
||||
installMessage: null,
|
||||
lastCheckedAt: null,
|
||||
formatVersion,
|
||||
formatLastCheckedAt,
|
||||
}),
|
||||
).toBe("更新已就绪:v1.2.3");
|
||||
} finally {
|
||||
await i18n.changeLanguage("en");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
DesktopAppUpdateInstallResult,
|
||||
DesktopReleaseChannel,
|
||||
} from "@/desktop/updates/desktop-updates";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export type DesktopAppUpdateStatus =
|
||||
| "idle"
|
||||
@@ -119,40 +120,44 @@ export function formatStatusText(input: {
|
||||
} = input;
|
||||
|
||||
if (status === "checking") {
|
||||
return "Checking for app updates...";
|
||||
return i18n.t("desktop.updates.status.checking");
|
||||
}
|
||||
|
||||
if (status === "installing") {
|
||||
return "Installing app update...";
|
||||
return i18n.t("desktop.updates.status.installing");
|
||||
}
|
||||
|
||||
if (status === "up-to-date") {
|
||||
if (lastCheckedAt != null) {
|
||||
return `Up to date. Last checked at ${formatLastCheckedAt(lastCheckedAt)}.`;
|
||||
return i18n.t("desktop.updates.status.upToDateWithLastChecked", {
|
||||
time: formatLastCheckedAt(lastCheckedAt),
|
||||
});
|
||||
}
|
||||
return "Up to date.";
|
||||
return i18n.t("desktop.updates.status.upToDate");
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
return "We'll let you know when the update is ready.";
|
||||
return i18n.t("desktop.updates.status.pending");
|
||||
}
|
||||
|
||||
if (status === "available") {
|
||||
if (availableUpdate?.latestVersion) {
|
||||
return `Update ready: ${formatVersion(availableUpdate.latestVersion)}`;
|
||||
return i18n.t("desktop.updates.status.availableWithVersion", {
|
||||
version: formatVersion(availableUpdate.latestVersion),
|
||||
});
|
||||
}
|
||||
return "An app update is ready to install.";
|
||||
return i18n.t("desktop.updates.status.available");
|
||||
}
|
||||
|
||||
if (status === "installed") {
|
||||
return installMessage ?? "App update installed. Restart required.";
|
||||
return installMessage ?? i18n.t("desktop.updates.status.installed");
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return "Failed to update app.";
|
||||
return i18n.t("desktop.updates.status.failed");
|
||||
}
|
||||
|
||||
return "Update status has not been checked yet.";
|
||||
return i18n.t("desktop.updates.status.idle");
|
||||
}
|
||||
|
||||
export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopAppUpdater {
|
||||
@@ -264,7 +269,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
|
||||
const message = getErrorMessage(error);
|
||||
deps.reportInstallError?.({
|
||||
error,
|
||||
message: "Unable to install the desktop app update.",
|
||||
message: i18n.t("desktop.updates.installError"),
|
||||
logLabel: "[DesktopUpdater] Failed to install app update",
|
||||
});
|
||||
commit({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export interface DesktopAppUpdateCheckResult {
|
||||
hasUpdate: boolean;
|
||||
@@ -136,7 +137,7 @@ export async function installDesktopAppUpdate({
|
||||
return {
|
||||
installed: result.installed === true,
|
||||
version: toStringOrNull(result.version),
|
||||
message: toStringOrNull(result.message) ?? "Update completed.",
|
||||
message: toStringOrNull(result.message) ?? i18n.t("desktop.updates.status.installed"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
resolveUpdateCalloutDescriptor,
|
||||
type ResolveUpdateCalloutInput,
|
||||
@@ -104,4 +105,19 @@ describe("resolveUpdateCalloutDescriptor", () => {
|
||||
?.dismissalKey,
|
||||
).toBe("desktop-update:available:1.2.4");
|
||||
});
|
||||
|
||||
it("uses the active app language for local callout chrome", async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
try {
|
||||
const descriptor = resolveUpdateCalloutDescriptor(input());
|
||||
|
||||
expect(descriptor?.title).toBe("有可用更新");
|
||||
expect(descriptor?.actions).toEqual([
|
||||
{ role: "changelog", label: "更新内容" },
|
||||
{ role: "install", label: "安装并重启", variant: "primary", disabled: false },
|
||||
]);
|
||||
} finally {
|
||||
await i18n.changeLanguage("en");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user