Complete client UI i18n migration
Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
|
||||
|
||||
function TestIcon() {
|
||||
@@ -41,4 +42,27 @@ describe("buildDraftPanelDescriptor", () => {
|
||||
statusBucket: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the active language for draft descriptor chrome", async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
const idleDescriptor = buildDraftPanelDescriptor({
|
||||
isCreating: false,
|
||||
icon: TestIcon,
|
||||
});
|
||||
const creatingDescriptor = buildDraftPanelDescriptor({
|
||||
isCreating: true,
|
||||
pendingPrompt: " ",
|
||||
icon: TestIcon,
|
||||
});
|
||||
|
||||
expect(idleDescriptor).toMatchObject({
|
||||
label: "新建 Agent",
|
||||
subtitle: "新建 Agent",
|
||||
});
|
||||
expect(creatingDescriptor).toMatchObject({
|
||||
label: "新建 Agent",
|
||||
subtitle: "正在创建 Agent",
|
||||
});
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { TFunction } from "i18next";
|
||||
import { SquarePen } from "lucide-react-native";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, Text, View } from "react-native";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -149,13 +151,14 @@ function buildChatAgentFromState(
|
||||
function renderChatAgentNonReadyView(args: {
|
||||
viewState: AgentScreenViewState;
|
||||
effectiveAgent: AgentScreenAgent | null;
|
||||
t: TFunction;
|
||||
}): React.ReactElement | null {
|
||||
const { viewState, effectiveAgent } = args;
|
||||
const { viewState, effectiveAgent, t } = args;
|
||||
if (viewState.tag === "not_found") {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-not-found">
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
<Text style={styles.errorText}>{t("agentPanel.states.notFound")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -164,7 +167,7 @@ function renderChatAgentNonReadyView(args: {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-load-error">
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Failed to load agent</Text>
|
||||
<Text style={styles.errorText}>{t("agentPanel.states.failedToLoad")}</Text>
|
||||
<Text style={styles.statusText}>{viewState.message}</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -443,6 +446,7 @@ function AgentPanelContent({
|
||||
isPaneFocused: boolean;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedAgentId = agentId.trim() || undefined;
|
||||
const resolvedServerId = serverId.trim() || undefined;
|
||||
const daemons = useHosts();
|
||||
@@ -456,7 +460,8 @@ function AgentPanelContent({
|
||||
const daemon = connectionServerId
|
||||
? (daemons.find((entry) => entry.serverId === connectionServerId) ?? null)
|
||||
: null;
|
||||
const serverLabel = daemon?.label ?? connectionServerId ?? "Selected host";
|
||||
const serverLabel =
|
||||
daemon?.label ?? connectionServerId ?? t("agentPanel.unavailable.selectedHost");
|
||||
const isUnknownDaemon = Boolean(connectionServerId && !daemon);
|
||||
const connectionStatus: HostRuntimeConnectionStatus =
|
||||
isUnknownDaemon && runtimeConnectionStatus === "connecting"
|
||||
@@ -471,6 +476,7 @@ function AgentPanelContent({
|
||||
connectionStatus={connectionStatus}
|
||||
lastError={lastConnectionError}
|
||||
isUnknownDaemon={isUnknownDaemon}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -505,6 +511,7 @@ function AgentPanelBody({
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
@@ -601,7 +608,7 @@ function AgentPanelBody({
|
||||
return (
|
||||
<View style={styles.container} testID="agent-not-found">
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
<Text style={styles.errorText}>{t("agentPanel.states.notFound")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -611,7 +618,7 @@ function AgentPanelBody({
|
||||
return (
|
||||
<View style={styles.container} testID="agent-load-error">
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Failed to load agent</Text>
|
||||
<Text style={styles.errorText}>{t("agentPanel.states.failedToLoad")}</Text>
|
||||
<Text style={styles.statusText}>{lookupState.message}</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -670,6 +677,7 @@ function ChatAgentContent({
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const panelToast = useToastHost();
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
const streamViewRef = useRef<AgentStreamViewHandle>(null);
|
||||
@@ -797,12 +805,12 @@ function ChatAgentContent({
|
||||
}
|
||||
if (!reconnectToastArmedRef.current) {
|
||||
reconnectToastArmedRef.current = true;
|
||||
panelToast.api.show("Reconnecting...", {
|
||||
panelToast.api.show(t("agentPanel.states.reconnecting"), {
|
||||
durationMs: null,
|
||||
testID: "agent-reconnecting-toast",
|
||||
});
|
||||
}
|
||||
}, [connectionStatus, panelToast]);
|
||||
}, [connectionStatus, panelToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaneFocused || !agentId || !isConnected || !hasSession) {
|
||||
@@ -1014,6 +1022,7 @@ function ChatAgentContent({
|
||||
const nonReadyView = renderChatAgentNonReadyView({
|
||||
viewState,
|
||||
effectiveAgent,
|
||||
t,
|
||||
});
|
||||
if (nonReadyView) return nonReadyView;
|
||||
invariant(agentId, "agent id is defined when agent content is ready");
|
||||
@@ -1091,6 +1100,7 @@ function ChatAgentReadyContent({
|
||||
attentionController: ReturnType<typeof useAgentAttentionClear>;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const agentInputDraft = useAgentInputDraft({
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId,
|
||||
@@ -1161,8 +1171,8 @@ function ChatAgentReadyContent({
|
||||
{isArchivingCurrentAgent ? (
|
||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
|
||||
<Text style={styles.archivingTitle}>Archiving agent...</Text>
|
||||
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
|
||||
<Text style={styles.archivingTitle}>{t("agentPanel.states.archivingTitle")}</Text>
|
||||
<Text style={styles.archivingSubtitle}>{t("agentPanel.states.archivingSubtitle")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -1470,22 +1480,22 @@ function AgentSessionUnavailableState({
|
||||
connectionStatus,
|
||||
lastError,
|
||||
isUnknownDaemon = false,
|
||||
t,
|
||||
}: {
|
||||
serverLabel: string;
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
lastError: string | null;
|
||||
isUnknownDaemon?: boolean;
|
||||
t: TFunction;
|
||||
}) {
|
||||
if (isUnknownDaemon) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>
|
||||
Cannot open this agent because {serverLabel} is not configured on this device.
|
||||
</Text>
|
||||
<Text style={styles.statusText}>
|
||||
Add the host in Settings or open an agent on a configured server to continue.
|
||||
{t("agentPanel.unavailable.unknownHost", { serverLabel })}
|
||||
</Text>
|
||||
<Text style={styles.statusText}>{t("agentPanel.unavailable.addHost")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -1502,20 +1512,22 @@ function AgentSessionUnavailableState({
|
||||
<ActivityIndicator size="large" />
|
||||
<Text style={styles.loadingText}>
|
||||
{isPreparingSession
|
||||
? `Preparing ${serverLabel} session...`
|
||||
: `Connecting to ${serverLabel}...`}
|
||||
? t("agentPanel.unavailable.preparingSession", { serverLabel })
|
||||
: t("agentPanel.unavailable.connecting", { serverLabel })}
|
||||
</Text>
|
||||
<Text style={styles.statusText}>
|
||||
{isPreparingSession
|
||||
? "We will show this agent in a moment."
|
||||
: "We will show this agent once the host is online."}
|
||||
? t("agentPanel.unavailable.showSoon")
|
||||
: t("agentPanel.unavailable.showWhenOnline")}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.offlineTitle}>Reconnecting to {serverLabel}...</Text>
|
||||
<Text style={styles.offlineTitle}>
|
||||
{t("agentPanel.unavailable.reconnectingTo", { serverLabel })}
|
||||
</Text>
|
||||
<Text style={styles.offlineDescription}>
|
||||
We will show this agent again as soon as the host is reachable.
|
||||
{t("agentPanel.unavailable.showAgainWhenReachable")}
|
||||
</Text>
|
||||
{lastError ? <Text style={styles.offlineDetails}>{lastError}</Text> : null}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import type { PanelDescriptor, PanelIconProps } from "@/panels/panel-registry";
|
||||
|
||||
export function buildDraftPanelDescriptor(input: {
|
||||
@@ -7,11 +8,12 @@ export function buildDraftPanelDescriptor(input: {
|
||||
icon: ComponentType<PanelIconProps>;
|
||||
}): PanelDescriptor {
|
||||
const { icon, isCreating, pendingPrompt } = input;
|
||||
const creatingLabel = pendingPrompt?.trim() || "New Agent";
|
||||
const newAgentLabel = i18n.t("panels.draft.newAgent");
|
||||
const creatingLabel = pendingPrompt?.trim() || newAgentLabel;
|
||||
if (isCreating) {
|
||||
return {
|
||||
label: creatingLabel,
|
||||
subtitle: "Creating agent",
|
||||
subtitle: i18n.t("panels.draft.creatingAgent"),
|
||||
titleState: "ready",
|
||||
icon,
|
||||
statusBucket: "running",
|
||||
@@ -19,8 +21,8 @@ export function buildDraftPanelDescriptor(input: {
|
||||
}
|
||||
|
||||
return {
|
||||
label: "New Agent",
|
||||
subtitle: "New Agent",
|
||||
label: newAgentLabel,
|
||||
subtitle: newAgentLabel,
|
||||
titleState: "ready",
|
||||
icon,
|
||||
statusBucket: null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { FileText } from "lucide-react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FilePane } from "@/components/file-pane";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
@@ -25,6 +26,7 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
||||
}
|
||||
|
||||
function FilePanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
|
||||
const workspaceDirectory = workspaceAuthority?.ok
|
||||
@@ -34,7 +36,7 @@ function FilePanel() {
|
||||
if (!workspaceDirectory) {
|
||||
return (
|
||||
<View style={CENTERED_PADDED_STYLE}>
|
||||
<Text>Workspace execution directory not found.</Text>
|
||||
<Text>{t("panels.file.executionDirectoryMissing")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
@@ -25,6 +26,7 @@ function useSetupPanelDescriptor(
|
||||
target: { kind: "setup"; workspaceId: string },
|
||||
context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor {
|
||||
const { t } = useTranslation();
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: context.serverId,
|
||||
workspaceId: target.workspaceId,
|
||||
@@ -33,8 +35,8 @@ function useSetupPanelDescriptor(
|
||||
|
||||
if (snapshot?.status === "completed") {
|
||||
return {
|
||||
label: "Setup",
|
||||
subtitle: "Setup completed",
|
||||
label: t("workspace.setup.descriptor.label"),
|
||||
subtitle: t("workspace.setup.descriptor.completed"),
|
||||
titleState: "ready",
|
||||
icon: CheckCircle2,
|
||||
statusBucket: null,
|
||||
@@ -43,8 +45,8 @@ function useSetupPanelDescriptor(
|
||||
|
||||
if (snapshot?.status === "failed") {
|
||||
return {
|
||||
label: "Setup",
|
||||
subtitle: "Setup failed",
|
||||
label: t("workspace.setup.descriptor.label"),
|
||||
subtitle: t("workspace.setup.descriptor.failed"),
|
||||
titleState: "ready",
|
||||
icon: CircleAlert,
|
||||
statusBucket: null,
|
||||
@@ -52,8 +54,8 @@ function useSetupPanelDescriptor(
|
||||
}
|
||||
|
||||
return {
|
||||
label: "Setup",
|
||||
subtitle: "Workspace setup",
|
||||
label: t("workspace.setup.descriptor.label"),
|
||||
subtitle: t("workspace.setup.descriptor.workspace"),
|
||||
titleState: "ready",
|
||||
icon: SquareTerminal,
|
||||
statusBucket: snapshot?.status === "running" ? "running" : null,
|
||||
@@ -108,11 +110,14 @@ function resolveAutoExpandIndex(commands: { index: number; status: string }[]):
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveSetupStatusLabel(status: string | undefined): string {
|
||||
if (status === "running") return "Running";
|
||||
if (status === "completed") return "Completed";
|
||||
if (status === "failed") return "Failed";
|
||||
return "Waiting for setup output";
|
||||
function resolveSetupStatusLabel(
|
||||
status: string | undefined,
|
||||
labels: Record<CommandStatus | "waiting", string>,
|
||||
): string {
|
||||
if (status === "running") return labels.running;
|
||||
if (status === "completed") return labels.completed;
|
||||
if (status === "failed") return labels.failed;
|
||||
return labels.waiting;
|
||||
}
|
||||
|
||||
function resolveCommandLog(
|
||||
@@ -150,6 +155,7 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) {
|
||||
}
|
||||
|
||||
function SetupPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, target } = usePaneContext();
|
||||
invariant(target.kind === "setup", "SetupPanel requires setup target");
|
||||
|
||||
@@ -214,7 +220,16 @@ function SetupPanel() {
|
||||
}, []);
|
||||
|
||||
const autoExpandIndex = resolveAutoExpandIndex(commands);
|
||||
const statusLabel = resolveSetupStatusLabel(snapshot?.status);
|
||||
const statusLabels = useMemo(
|
||||
() => ({
|
||||
running: t("workspace.setup.status.running"),
|
||||
completed: t("workspace.setup.status.completed"),
|
||||
failed: t("workspace.setup.status.failed"),
|
||||
waiting: t("workspace.setup.status.waiting"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const statusLabel = resolveSetupStatusLabel(snapshot?.status, statusLabels);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -230,7 +245,7 @@ function SetupPanel() {
|
||||
{isWaiting ? (
|
||||
<View style={styles.waitingContainer}>
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.waitingText}>Setting up workspace...</Text>
|
||||
<Text style={styles.waitingText}>{t("workspace.setup.waiting")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{!isWaiting && hasNoSetupCommands ? (
|
||||
@@ -238,9 +253,9 @@ function SetupPanel() {
|
||||
<Text
|
||||
style={styles.emptyText}
|
||||
accessible
|
||||
accessibilityLabel="No setup commands ran for this workspace"
|
||||
accessibilityLabel={t("workspace.setup.accessibility.noCommands")}
|
||||
>
|
||||
No setup commands ran for this workspace.
|
||||
{t("workspace.setup.empty.noCommands")}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -303,6 +318,7 @@ function SetupCommandRow({
|
||||
errorMessage,
|
||||
onToggle,
|
||||
}: SetupCommandRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const handlePress = useCallback(() => {
|
||||
if (!isExpandable) return;
|
||||
onToggle(command.index, isAutoExpanded);
|
||||
@@ -348,7 +364,7 @@ function SetupCommandRow({
|
||||
showsVerticalScrollIndicator
|
||||
testID="workspace-setup-log"
|
||||
accessible
|
||||
accessibilityLabel="Workspace setup log"
|
||||
accessibilityLabel={t("workspace.setup.accessibility.log")}
|
||||
>
|
||||
<Text selectable dataSet={CODE_SURFACE_DATASET} style={styles.logText}>
|
||||
{processedLog}
|
||||
@@ -359,9 +375,9 @@ function SetupCommandRow({
|
||||
style={styles.logScrollContent}
|
||||
testID="workspace-setup-log"
|
||||
accessible
|
||||
accessibilityLabel="Workspace setup log"
|
||||
accessibilityLabel={t("workspace.setup.accessibility.log")}
|
||||
>
|
||||
<Text style={styles.emptyLogText}>No output</Text>
|
||||
<Text style={styles.emptyLogText}>{t("workspace.setup.log.noOutput")}</Text>
|
||||
</View>
|
||||
)}
|
||||
{hasError && errorMessage ? (
|
||||
@@ -394,6 +410,7 @@ function SetupCommandChevron({ showDetail }: { showDetail: boolean }) {
|
||||
}
|
||||
|
||||
function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) {
|
||||
const { t } = useTranslation();
|
||||
if (commands.length !== 0 || log.trim().length === 0) return null;
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -402,7 +419,7 @@ function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: s
|
||||
showsVerticalScrollIndicator
|
||||
testID="workspace-setup-log"
|
||||
accessible
|
||||
accessibilityLabel="Workspace setup log"
|
||||
accessibilityLabel={t("workspace.setup.accessibility.log")}
|
||||
>
|
||||
<Text selectable dataSet={CODE_SURFACE_DATASET} style={styles.logText}>
|
||||
{log}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal } from "lucide-react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
@@ -34,6 +35,7 @@ function useTerminalPanelDescriptor(
|
||||
target: { kind: "terminal"; terminalId: string },
|
||||
context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor {
|
||||
const { t } = useTranslation();
|
||||
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(context.serverId, context.workspaceId)!;
|
||||
const workspaceDirectory = workspaceAuthority.ok
|
||||
@@ -61,8 +63,10 @@ function useTerminalPanelDescriptor(
|
||||
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
|
||||
|
||||
return {
|
||||
label: trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ?? "Terminal",
|
||||
subtitle: "Terminal",
|
||||
label:
|
||||
trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ??
|
||||
t("workspace.tabs.fallback.terminal"),
|
||||
subtitle: t("workspace.tabs.fallback.terminal"),
|
||||
titleState: "ready",
|
||||
icon: Terminal,
|
||||
statusBucket: null,
|
||||
|
||||
Reference in New Issue
Block a user