Complete client UI i18n migration

Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
chyendongnhanh338
2026-06-11 14:15:35 +08:00
committed by GitHub
parent 41cf070fd8
commit 08ebe5e7e1
199 changed files with 16426 additions and 1821 deletions
@@ -1,4 +1,5 @@
import type { DesktopDialogBridge } from "@/desktop/host";
import { i18n } from "@/i18n/i18next";
import { isAbsolutePath } from "@/utils/path";
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
@@ -85,8 +86,13 @@ export async function openImagePathsWithDesktopDialog(
const options = {
directory: false,
multiple: true,
filters: [{ name: "Images", extensions: IMAGE_FILE_EXTENSIONS }],
title: "Attach images",
filters: [
{
name: i18n.t("imageAttachmentPicker.dialogFilterName"),
extensions: IMAGE_FILE_EXTENSIONS,
},
],
title: i18n.t("imageAttachmentPicker.dialogTitle"),
};
const dialogOpen = dialog?.open;
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import type { AutocompleteOption } from "@/components/ui/autocomplete";
import {
useAgentCommandsQuery,
@@ -115,13 +117,14 @@ function mapDirectorySuggestionsToEntries(payload: {
}));
}
function mapCommandToOption(entry: AvailableCommand): AgentAutocompleteOption {
function mapCommandToOption(entry: AvailableCommand, t: TFunction): AgentAutocompleteOption {
const command = entry.command;
const base = {
id: command.name,
label: `/${command.name}`,
detail: command.argumentHint || undefined,
description: command.description,
description:
entry.source === "client" ? t(entry.command.descriptionKey) : entry.command.description,
kind: "command" as const,
};
if (entry.source === "client") {
@@ -148,6 +151,7 @@ interface BuildAutocompleteOptionsInput {
activeSlashCommand: SlashCommandRange | null;
activeFileMention: FileMentionRange | null;
fileSuggestions: DirectorySuggestionEntry[];
t: TFunction;
}
function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) {
@@ -177,7 +181,7 @@ function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) {
input.commandFilterQuery,
);
const orderedMatches = orderAutocompleteOptions(matches);
return orderedMatches.map(mapCommandToOption);
return orderedMatches.map((entry) => mapCommandToOption(entry, input.t));
}
const activeFileMention = input.activeFileMention;
@@ -258,9 +262,12 @@ function resolveAutocompleteErrorMessage(args: {
isCommandError: boolean;
commandError: Error | null;
fileSuggestionsError: unknown;
t: TFunction;
}): string | undefined {
if (args.mode === "command") {
return args.isCommandError ? (args.commandError?.message ?? "Failed to load") : undefined;
return args.isCommandError
? (args.commandError?.message ?? args.t("agentAutocomplete.failedToLoad"))
: undefined;
}
if (args.mode === "file") {
return args.fileSuggestionsError instanceof Error
@@ -271,6 +278,7 @@ function resolveAutocompleteErrorMessage(args: {
}
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
const { t } = useTranslation();
const {
userInput,
cursorIndex,
@@ -366,7 +374,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
],
queryFn: async (): Promise<DirectorySuggestionEntry[]> => {
if (!client) {
throw new Error("Daemon client unavailable");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
const response = await client.getDirectorySuggestions({
cwd: autocompleteCwd,
@@ -402,6 +410,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
isDraftContext,
isVisible,
mode,
t,
}),
[
activeFileMention,
@@ -412,6 +421,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
isDraftContext,
isVisible,
mode,
t,
],
);
@@ -488,10 +498,15 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
isCommandError: isError,
commandError: error,
fileSuggestionsError: fileSuggestionsQuery.error,
t,
});
const loadingText = mode === "file" ? "Searching workspace..." : "Loading commands...";
const emptyText = mode === "file" ? "No files or directories found" : "No commands found";
const loadingText =
mode === "file"
? t("agentAutocomplete.searchingWorkspace")
: t("agentAutocomplete.loadingCommands");
const emptyText =
mode === "file" ? t("agentAutocomplete.noFiles") : t("agentAutocomplete.noCommands");
return {
isVisible,
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
@@ -65,6 +66,7 @@ export function useAgentCommandsQuery({
enabled = true,
draftConfig,
}: UseAgentCommandsQueryOptions) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -72,7 +74,7 @@ export function useAgentCommandsQuery({
queryKey: agentCommandsQueryKey(serverId, agentId, draftConfig),
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return fetchAgentCommands({ client, agentId, draftConfig });
},
+3 -1
View File
@@ -5,6 +5,7 @@ import type {
} from "@getpaseo/client/internal/daemon-client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
@@ -76,6 +77,7 @@ export function useAgentHistory(options: {
serverId?: string | null;
enabled?: boolean;
}): AgentHistoryResult {
const { t } = useTranslation();
const daemons = useHosts();
const serverId = useMemo(() => {
const value = options.serverId;
@@ -102,7 +104,7 @@ export function useAgentHistory(options: {
lastPage.pageInfo.hasMore ? lastPage.pageInfo.nextCursor : null,
queryFn: async ({ pageParam }) => {
if (!serverId || !client) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return fetchAgentHistoryPage({ client, serverId, cursor: pageParam });
},
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -9,6 +10,7 @@ import {
rejectInitDeferred,
} from "@/utils/agent-initialization";
import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { i18n } from "@/i18n/i18next";
export const INIT_TIMEOUT_MS = 30_000;
@@ -19,6 +21,7 @@ export interface EnsureAgentIsInitializedInput {
agentId: string;
client: Pick<DaemonClient, "fetchAgentTimeline"> | null;
setAgentInitializing: SetAgentInitializing;
hostDisconnectedMessage?: string;
}
export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): Promise<void> {
@@ -48,7 +51,10 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
if (!client) {
setAgentInitializing(agentId, false);
rejectInitDeferred(key, new Error("Host is not connected"));
rejectInitDeferred(
key,
new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")),
);
return deferred.promise;
}
@@ -64,12 +70,13 @@ export interface RefreshAgentInput {
agentId: string;
client: Pick<DaemonClient, "refreshAgent" | "fetchAgentTimeline"> | null;
setAgentInitializing: SetAgentInitializing;
hostDisconnectedMessage?: string;
}
export async function refreshAgent(input: RefreshAgentInput): Promise<void> {
const { agentId, client, setAgentInitializing } = input;
if (!client) {
throw new Error("Host is not connected");
throw new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
}
setAgentInitializing(agentId, true);
@@ -105,6 +112,7 @@ export function useAgentInitialization({
serverId: string;
client: DaemonClient | null;
}) {
const { t } = useTranslation();
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const setAgentInitializing = useMemo(
() => createSetAgentInitializing(serverId, setInitializingAgents),
@@ -113,13 +121,25 @@ export function useAgentInitialization({
const ensureAgentIsInitializedCallback = useCallback(
(agentId: string): Promise<void> =>
ensureAgentIsInitialized({ serverId, agentId, client, setAgentInitializing }),
[client, serverId, setAgentInitializing],
ensureAgentIsInitialized({
serverId,
agentId,
client,
setAgentInitializing,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
[client, serverId, setAgentInitializing, t],
);
const refreshAgentCallback = useCallback(
(agentId: string): Promise<void> => refreshAgent({ agentId, client, setAgentInitializing }),
[client, setAgentInitializing],
(agentId: string): Promise<void> =>
refreshAgent({
agentId,
client,
setAgentInitializing,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
[client, setAgentInitializing, t],
);
return {
+3 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useMemo } from "react";
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useSessionStore } from "@/stores/session-store";
import { agentHistoryQueryKey } from "./agent-history-query-key";
@@ -382,6 +383,7 @@ export function usePendingArchiveAgentIds(serverId: string): ReadonlySet<string>
export function useArchiveAgent() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const pendingQuery = useArchiveAgentPendingQuery();
@@ -389,7 +391,7 @@ export function useArchiveAgent() {
mutationFn: async (input: ArchiveAgentInput): Promise<{ archivedAt: string }> => {
const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null;
if (!client) {
throw new Error("Daemon client not available");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await client.archiveAgent(input.agentId);
},
+17 -15
View File
@@ -1,5 +1,6 @@
import { useState, useCallback, useMemo } from "react";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ComboboxOption } from "@/components/ui/combobox";
import type { ToastApi } from "@/components/toast-host";
@@ -35,13 +36,14 @@ export function useBranchSwitcher({
toast,
queryClient,
}: UseBranchSwitcherInput): UseBranchSwitcherResult {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const branchSuggestionsQuery = useQuery({
queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId],
queryFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
const payload = await client.getBranchSuggestions({
cwd: normalizedWorkspaceId,
@@ -85,35 +87,34 @@ export function useBranchSwitcher({
const targetStash = stashPayload.entries.find((e) => e.branch === branchId);
if (!targetStash) return;
const shouldRestore = await confirmDialog({
title: "Restore stashed changes?",
message:
"This branch has stashed changes from a previous session. Would you like to restore them?",
confirmLabel: "Restore",
cancelLabel: "Later",
title: t("branchSwitcher.restoreStashTitle"),
message: t("branchSwitcher.restoreStashMessage"),
confirmLabel: t("branchSwitcher.restore"),
cancelLabel: t("branchSwitcher.later"),
});
if (!shouldRestore) return;
const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index);
if (popPayload.error) {
toast.error(popPayload.error.message);
} else {
toast.show("Stashed changes restored");
toast.show(t("branchSwitcher.stashRestored"));
}
await invalidateStashAndCheckout();
} catch {
// Non-critical — user can still restore on next branch switch
}
},
[client, invalidateStashAndCheckout, normalizedWorkspaceId, toast],
[client, invalidateStashAndCheckout, normalizedWorkspaceId, toast, t],
);
const stashAndSwitch = useCallback(
async (branchId: string) => {
if (!client) return;
const shouldStash = await confirmDialog({
title: "Uncommitted changes",
message: "You have uncommitted changes. Stash them before switching branches?",
confirmLabel: "Stash & Switch",
cancelLabel: "Cancel",
title: t("branchSwitcher.uncommittedTitle"),
message: t("branchSwitcher.uncommittedMessage"),
confirmLabel: t("branchSwitcher.stashAndSwitch"),
cancelLabel: t("common.actions.cancel"),
});
if (!shouldStash) return;
@@ -133,10 +134,10 @@ export function useBranchSwitcher({
}
await invalidateStashAndCheckout();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to stash changes");
toast.error(err instanceof Error ? err.message : t("branchSwitcher.failedToStash"));
}
},
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast],
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast, t],
);
const handleBranchSelect = useCallback(
@@ -160,7 +161,7 @@ export function useBranchSwitcher({
await invalidateStashAndCheckout();
await maybeRestoreStashForBranch(branchId);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to switch branch");
toast.error(err instanceof Error ? err.message : t("branchSwitcher.failedToSwitch"));
}
})();
},
@@ -171,6 +172,7 @@ export function useBranchSwitcher({
maybeRestoreStashForBranch,
normalizedWorkspaceId,
stashAndSwitch,
t,
toast,
],
);
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import { i18n } from "@/i18n/i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
@@ -25,7 +26,7 @@ export function useClearWorkspaceAttention({
}
const client = getHostRuntimeStore().getClient(serverId);
if (!client) {
throw new Error("Host is not connected");
throw new Error(i18n.t("workspace.terminal.hostDisconnected"));
}
await client.clearWorkspaceAttention(workspaceId);
}, [hasClearableAttention, serverId, workspaceId]);
+18 -9
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TextInput } from "react-native";
import { router, usePathname, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
@@ -52,7 +53,10 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
interface CommandCenterActionDefinition {
id: string;
title: string;
titleKey:
| "shell.commandCenter.openProject"
| "shell.commandCenter.home"
| "sidebar.actions.settings";
icon?: "plus" | "settings" | "home";
actionId?: string;
keywords: string[];
@@ -62,7 +66,7 @@ interface CommandCenterActionDefinition {
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
{
id: "new-agent",
title: "Open project",
titleKey: "shell.commandCenter.openProject",
icon: "plus",
actionId: "new-agent",
keywords: ["open", "project", "folder", "workspace", "repo"],
@@ -70,24 +74,28 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
},
{
id: "home",
title: "Home",
titleKey: "shell.commandCenter.home",
icon: "home",
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
routeKind: "home",
},
{
id: "settings",
title: "Settings",
titleKey: "sidebar.actions.settings",
icon: "settings",
keywords: ["settings", "preferences", "config", "configuration"],
routeKind: "settings",
},
];
function matchesActionQuery(query: string, action: CommandCenterActionDefinition): boolean {
function matchesActionQuery(
query: string,
action: CommandCenterActionDefinition,
title: string,
): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
if (action.title.toLowerCase().includes(normalized)) {
if (title.toLowerCase().includes(normalized)) {
return true;
}
return action.keywords.some((keyword) => keyword.includes(normalized));
@@ -129,6 +137,7 @@ function resolveActionShortcutKeys(
}
export function useCommandCenter() {
const { t } = useTranslation();
const pathname = usePathname();
const routeActiveServerId = useActiveServerId();
const { overrides } = useKeyboardShortcutOverrides();
@@ -174,7 +183,7 @@ export function useCommandCenter() {
}
return COMMAND_CENTER_ACTIONS.filter((action) => {
if (action.routeKind === "home" && !homeRoute) return false;
return matchesActionQuery(query, action);
return matchesActionQuery(query, action, t(action.titleKey));
}).map<CommandCenterActionItem>((action) => {
let route: Href | undefined;
if (action.routeKind === "settings") route = settingsRoute;
@@ -182,13 +191,13 @@ export function useCommandCenter() {
return {
kind: "action",
id: action.id,
title: action.title,
title: t(action.titleKey),
icon: action.icon,
route,
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
};
});
}, [open, query, settingsRoute, homeRoute, overrides]);
}, [open, query, settingsRoute, homeRoute, overrides, t]);
const items = useMemo(() => {
if (!open) {
+3 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
@@ -14,6 +15,7 @@ interface UseDaemonConfigResult {
}
export function useDaemonConfig(serverId: string | null): UseDaemonConfigResult {
const { t } = useTranslation();
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
@@ -25,7 +27,7 @@ export function useDaemonConfig(serverId: string | null): UseDaemonConfigResult
staleTime: Infinity,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const result = await client.getDaemonConfig();
return result.config;
@@ -1,3 +1,5 @@
import { i18n } from "@/i18n/i18next";
export type DictationStatus = "idle" | "recording" | "uploading" | "failed";
export interface UseDictationOptions {
@@ -37,5 +39,5 @@ export const toError = (error: unknown): Error => {
if (typeof error === "string" && error.trim().length > 0) {
return new Error(error);
}
return new Error("An unexpected error occurred while handling dictation.");
return new Error(i18n.t("common.errors.unexpectedDictationError"));
};
+10 -2
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { DictationStreamSender } from "@/dictation/dictation-stream-sender";
import { useDictationAudioSource } from "@/hooks/use-dictation-audio-source";
@@ -14,6 +15,7 @@ import {
} from "./use-dictation.shared";
export function useDictation(options: UseDictationOptions): UseDictationResult {
const { t } = useTranslation();
const {
client,
onTranscript,
@@ -389,7 +391,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
try {
if (!client?.isConnected) {
throw new Error("Daemon client is disconnected");
throw new Error(t("common.errors.daemonClientDisconnected"));
}
senderRef.current.resetStreamForReplay();
const finalSeq = senderRef.current.getFinalSeq();
@@ -401,7 +403,13 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
}
handleDictationFailure(err);
}
}, [client, ensureFinalTranscript, handleDictationFailure, handleStreamingTranscriptionSuccess]);
}, [
client,
ensureFinalTranscript,
handleDictationFailure,
handleStreamingTranscriptionSuccess,
t,
]);
const discardFailedDictation = useCallback(() => {
setIsProcessing(false);
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { AgentProvider, AgentSessionConfig } from "@getpaseo/protocol/agent-types";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
@@ -23,6 +24,7 @@ export function useDraftAgentFeatures(input: {
thinkingOptionId: string | null | undefined;
initialFeatureValues?: Record<string, unknown>;
}) {
const { t } = useTranslation();
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } =
input;
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>(
@@ -67,7 +69,7 @@ export function useDraftAgentFeatures(input: {
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !draftConfig) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.listProviderFeatures(draftConfig);
if (payload.error) {
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
@@ -51,6 +52,7 @@ export function buildWorkspaceExplorerStateKey(scope: FileExplorerWorkspaceScope
}
export function useFileExplorerActions(params: { serverId: string } & FileExplorerWorkspaceScope) {
const { t } = useTranslation();
const { serverId, workspaceId, workspaceRoot } = params;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
@@ -114,7 +116,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: "Workspace is unavailable",
lastError: t("workspace.fileExplorer.states.unavailable"),
pendingRequest: null,
}));
return false;
@@ -124,7 +126,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: "Host is not connected",
lastError: t("workspace.terminal.hostDisconnected"),
pendingRequest: null,
}));
return false;
@@ -153,13 +155,16 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : "Failed to list directory",
lastError:
error instanceof Error
? error.message
: t("workspace.fileExplorer.errors.failedToListDirectory"),
pendingRequest: null,
}));
return false;
}
},
[client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey],
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
);
const requestFilePreview = useCallback(
@@ -179,7 +184,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: "Workspace is unavailable",
lastError: t("workspace.fileExplorer.states.unavailable"),
pendingRequest: null,
}));
return;
@@ -189,7 +194,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: "Host is not connected",
lastError: t("workspace.terminal.hostDisconnected"),
pendingRequest: null,
}));
return;
@@ -218,21 +223,21 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : "Failed to load file preview",
lastError: error instanceof Error ? error.message : t("panels.file.failedToLoadPreview"),
pendingRequest: null,
}));
}
},
[client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey],
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
);
const requestFileDownloadToken = useCallback(
async (path: string) => {
if (!normalizedWorkspaceRoot) {
throw new Error("Workspace is unavailable");
throw new Error(t("workspace.fileExplorer.states.unavailable"));
}
if (!client) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.requestDownloadToken(normalizedWorkspaceRoot, path);
if (payload.error) {
@@ -240,7 +245,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
}
return payload;
},
[client, normalizedWorkspaceRoot],
[client, normalizedWorkspaceRoot, t],
);
const selectExplorerEntry = useCallback(
@@ -1,6 +1,7 @@
import { useCallback, useRef } from "react";
import { Alert } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { useTranslation } from "react-i18next";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import {
normalizePickedImageAssets,
@@ -14,6 +15,7 @@ interface UseImageAttachmentPickerResult {
}
export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
const { t } = useTranslation();
const [mediaPermission, requestMediaPermission] = ImagePicker.useMediaLibraryPermissions();
const isPickingRef = useRef(false);
@@ -31,14 +33,14 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
if (!currentPermission?.granted) {
Alert.alert(
"Permission required",
"Please allow access to your photo library to attach images.",
t("imageAttachmentPicker.permissionTitle"),
t("imageAttachmentPicker.permissionMessage"),
);
return false;
}
return true;
}, [mediaPermission, requestMediaPermission]);
}, [mediaPermission, requestMediaPermission, t]);
const pickImages = useCallback(async () => {
if (isPickingRef.current) {
@@ -83,12 +85,12 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
return await normalizePickedImageAssets(result.assets);
} catch (error) {
console.error("[ImageAttachmentPicker] Failed to pick image:", error);
Alert.alert("Error", "Failed to select image");
Alert.alert(t("imageAttachmentPicker.errorTitle"), t("imageAttachmentPicker.failedToSelect"));
return null;
} finally {
isPickingRef.current = false;
}
}, [ensurePermission]);
}, [ensurePermission, t]);
return { pickImages };
}
@@ -1,5 +1,7 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { ToastApi } from "@/components/toast-host";
import { i18n } from "@/i18n/i18next";
import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store";
import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan";
@@ -27,13 +29,15 @@ export interface LoadOlderAgentHistoryDeps {
setInFlight: (value: boolean) => void;
toast?: ToastApi | null;
logger?: LoadOlderAgentHistoryLogger;
failedMessage?: string;
}
export async function loadOlderAgentHistory(
agentId: string,
deps: LoadOlderAgentHistoryDeps,
): Promise<void> {
const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger } = deps;
const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } =
deps;
if (!client || !cursor || !hasOlder || isLoadingOlder) {
return;
}
@@ -46,7 +50,7 @@ export async function loadOlderAgentHistory(
);
} catch (error) {
(logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error);
toast?.show("Couldn't load older history", {
toast?.show(failedMessage ?? i18n.t("loadOlderHistory.failed"), {
durationMs: 2200,
testID: "agent-load-older-history-toast",
});
@@ -64,6 +68,7 @@ export function useLoadOlderAgentHistory({
agentId: string;
toast?: ToastApi | null;
}) {
const { t } = useTranslation();
const hasOlder =
useSessionStore((state) => state.sessions[serverId]?.agentTimelineHasOlder.get(agentId)) ===
true;
@@ -98,8 +103,9 @@ export function useLoadOlderAgentHistory({
isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true,
setInFlight,
toast,
failedMessage: t("loadOlderHistory.failed"),
});
}, [agentId, serverId, setInFlight, toast]);
}, [agentId, serverId, setInFlight, toast, t]);
return {
isLoadingOlder,
+18 -3
View File
@@ -1,5 +1,6 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type {
CheckoutPrStatusResponse,
@@ -9,6 +10,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { prPaneTimelineQueryKey } from "@/git/query-keys";
import { i18n } from "@/i18n/i18next";
type CheckoutPrStatus = CheckoutPrStatusResponse["payload"]["status"];
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
@@ -153,6 +155,8 @@ export interface SelectPrPaneStateInput {
timelineError: Error | null;
timelineIsLoading: boolean;
timelineIsFetching: boolean;
statusLoadFailedLabel?: string;
activityLoadFailedLabel?: string;
}
export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataResult {
@@ -176,6 +180,10 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
statusError: input.statusError,
timelineError: input.timelineError,
timelinePayloadError: input.timelinePayload?.error ?? null,
statusLoadFailedLabel:
input.statusLoadFailedLabel ?? i18n.t("workspace.git.pr.errors.statusLoadFailed"),
activityLoadFailedLabel:
input.activityLoadFailedLabel ?? i18n.t("workspace.git.pr.errors.activityLoadFailed"),
}),
githubFeaturesEnabled: input.githubFeaturesEnabled,
};
@@ -187,6 +195,7 @@ export function usePrPaneData({
enabled = true,
timelineEnabled = enabled,
}: UsePrPaneDataOptions): UsePrPaneDataResult {
const { t } = useTranslation();
const daemonClient = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled });
@@ -221,7 +230,7 @@ export function usePrPaneData({
identity.repoOwner === null ||
identity.repoName === null
) {
throw new Error("Daemon client not available");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return fetchPrPaneTimelinePage({
client: daemonClient,
@@ -255,6 +264,8 @@ export function usePrPaneData({
timelineError: timelineQuery.error,
timelineIsLoading: timelineQuery.isLoading,
timelineIsFetching: timelineQuery.isFetching,
statusLoadFailedLabel: t("workspace.git.pr.errors.statusLoadFailed"),
activityLoadFailedLabel: t("workspace.git.pr.errors.activityLoadFailed"),
});
}
@@ -263,14 +274,18 @@ function firstNonSuppressedError({
statusError,
timelineError,
timelinePayloadError,
statusLoadFailedLabel,
activityLoadFailedLabel,
}: {
statusPayloadError: CheckoutPrStatusPayloadError;
statusError: Error | null;
timelineError: Error | null;
timelinePayloadError: PullRequestTimeline["error"];
statusLoadFailedLabel: string;
activityLoadFailedLabel: string;
}): Error | null {
if (statusPayloadError) {
return new Error(statusPayloadError.message || "Unable to load pull request status");
return new Error(statusPayloadError.message || statusLoadFailedLabel);
}
if (statusError) {
@@ -282,7 +297,7 @@ function firstNonSuppressedError({
}
if (timelinePayloadError) {
return new Error(timelinePayloadError.message || "Unable to load pull request activity");
return new Error(timelinePayloadError.message || activityLoadFailedLabel);
}
return null;
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { ProjectIcon } from "@getpaseo/protocol/messages";
@@ -19,6 +20,7 @@ interface UseProjectIconQueryOptions {
}
export function useProjectIconQuery({ serverId, cwd }: UseProjectIconQueryOptions) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -26,7 +28,7 @@ export function useProjectIconQuery({ serverId, cwd }: UseProjectIconQueryOption
queryKey: projectIconQueryKey(serverId, cwd),
queryFn: async (): Promise<ProjectIcon | null> => {
if (!client) {
throw new Error("Daemon client not available");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
const result = await client.requestProjectIcon(cwd);
return result.icon;
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo } from "react";
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
@@ -112,6 +113,7 @@ export function useProvidersSnapshot(
serverId: string | null,
options: UseProvidersSnapshotOptions = {},
): UseProvidersSnapshotResult {
const { t } = useTranslation();
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
@@ -129,7 +131,7 @@ export function useProvidersSnapshot(
staleTime: 60_000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return fetchProvidersSnapshot({ client, cwd });
},
@@ -2,6 +2,7 @@ import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { queryClient as appQueryClient } from "@/query/query-client";
import type { AppLanguage } from "@/i18n/locales";
import {
DEFAULT_DESKTOP_SETTINGS,
loadDesktopSettings,
@@ -58,6 +59,7 @@ export {
};
export type {
AppSettings,
AppLanguage,
DesktopSettingsBridge,
KeyValueStorage,
ReleaseChannel,
@@ -143,6 +145,9 @@ export function useSettings(): UseSettingsReturn {
if (updates.theme !== undefined) {
appUpdates.theme = updates.theme;
}
if (updates.language !== undefined) {
appUpdates.language = updates.language;
}
if (updates.sendBehavior !== undefined) {
appUpdates.sendBehavior = updates.sendBehavior;
}
@@ -47,11 +47,20 @@ describe("loadAppSettingsFromStorage", () => {
const result = await loadAppSettingsFromStorage(deps);
expect(result).toEqual(DEFAULT_CLIENT_SETTINGS);
expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system");
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(
JSON.stringify(DEFAULT_CLIENT_SETTINGS),
);
});
it("defaults language to system when storage is empty", async () => {
const deps = makeDeps();
const result = await loadAppSettingsFromStorage(deps);
expect(result.language).toBe("system");
});
it("loads configured terminal scrollback lines from app settings", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
@@ -95,6 +104,30 @@ describe("loadAppSettingsFromStorage", () => {
});
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(JSON.stringify(result));
});
it("loads a persisted explicit language", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ language: "zh-CN" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.language).toBe("zh-CN");
});
it("drops an unknown persisted language back to system", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ language: "klingon" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.language).toBe("system");
});
});
describe("loadSettingsFromStorage", () => {
@@ -1,6 +1,7 @@
import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight";
import type { QueryClient } from "@tanstack/react-query";
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
import { parseAppLanguage, type AppLanguage } from "@/i18n/locales";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
export const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -26,6 +27,7 @@ export const MAX_FONT_FAMILY_LENGTH = 200;
export interface AppSettings {
theme: ThemeName | "auto";
language: AppLanguage;
sendBehavior: SendBehavior;
serviceUrlBehavior: ServiceUrlBehavior;
terminalScrollbackLines: number;
@@ -43,6 +45,7 @@ export interface Settings extends AppSettings {
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
theme: "auto",
language: "system",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
@@ -149,6 +152,10 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
result.theme = stored.theme;
}
const language = parseAppLanguage(stored.language);
if (language !== null) {
result.language = language;
}
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
result.sendBehavior = stored.sendBehavior;
}