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
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useTranslation } from "react-i18next";
import {
buildTerminalsQueryKey,
canCreateWorkspaceTerminal,
@@ -51,6 +52,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
onWorkspacePathUnavailable,
onTerminalCreateQueued,
} = input;
const { t } = useTranslation();
const queryClient = useQueryClient();
const [pendingCreateInput, setPendingCreateInput] = useState<PendingTerminalCreateInput | null>(
null,
@@ -69,7 +71,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
enabled: canCreateNow,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return await client.listTerminals(workspaceDirectory);
},
@@ -106,7 +108,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
const createMutation = useMutation({
mutationFn: async (_input?: PendingTerminalCreateInput) => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return await client.createTerminal(workspaceDirectory);
},
@@ -134,7 +136,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
const killMutation = useMutation({
mutationFn: async (terminalId: string) => {
if (!client) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.killTerminal(terminalId);
if (!payload.success) {
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
import { type QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ListTerminalsResponse } from "@getpaseo/protocol/messages";
import { useTranslation } from "react-i18next";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { useSessionStore } from "@/stores/session-store";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
@@ -31,6 +32,7 @@ export function useWorkspaceTabRename(
input: UseWorkspaceTabRenameInput,
): UseWorkspaceTabRenameResult {
const { client, normalizedServerId, queryClient, terminalsData, terminalsQueryKey } = input;
const { t } = useTranslation();
const [renamingTab, setRenamingTab] = useState<RenamingTabState | null>(null);
const handleRenameTab = useCallback(
@@ -57,7 +59,7 @@ export function useWorkspaceTabRename(
async (nextTitle: string) => {
if (!renamingTab) return;
if (!client) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const trimmed = nextTitle.trim();
if (renamingTab.kind === "terminal") {
@@ -79,7 +81,7 @@ export function useWorkspaceTabRename(
queryKey: ["allAgents", normalizedServerId],
});
},
[client, normalizedServerId, queryClient, renamingTab, terminalsQueryKey],
[client, normalizedServerId, queryClient, renamingTab, terminalsQueryKey, t],
);
const handleRenameModalClose = useCallback(() => {
@@ -105,7 +107,11 @@ export function WorkspaceTabRenameModal({
onClose,
onSubmit,
}: WorkspaceTabRenameModalProps) {
const title = renamingTab?.kind === "terminal" ? "Rename terminal" : "Rename agent";
const { t } = useTranslation();
const title =
renamingTab?.kind === "terminal"
? t("workspace.tabs.menu.renameTerminal")
: t("workspace.tabs.menu.renameAgent");
const initialValue = renamingTab?.currentTitle ?? "";
const testID = renamingTab
? `workspace-tab-rename-modal-${renamingTab.kind}-${renamingTab.id}`
@@ -115,7 +121,7 @@ export function WorkspaceTabRenameModal({
visible={renamingTab !== null}
title={title}
initialValue={initialValue}
submitLabel="Rename"
submitLabel={t("workspace.tabs.menu.rename")}
maxLength={200}
onClose={onClose}
onSubmit={onSubmit}
@@ -1,5 +1,6 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { i18n } from "@/i18n/i18next";
export interface BulkClosableTabGroups {
agentTabs: Array<{ tabId: string; agentId: string }>;
@@ -7,6 +8,31 @@ export interface BulkClosableTabGroups {
otherTabs: Array<{ tabId: string; target: WorkspaceTabDescriptor["target"] }>;
}
export interface BulkCloseConfirmationLabels {
all: (input: { agents: number; terminals: number; tabs: number }) => string;
agentsAndTerminals: (input: { agents: number; terminals: number }) => string;
terminalsAndTabs: (input: { terminals: number; tabs: number }) => string;
agentsAndTabs: (input: { agents: number; tabs: number }) => string;
terminals: (input: { terminals: number }) => string;
tabs: (input: { tabs: number }) => string;
agents: (input: { agents: number }) => string;
}
export const DEFAULT_BULK_CLOSE_CONFIRMATION_LABELS: BulkCloseConfirmationLabels = {
all: ({ agents, terminals, tabs }) =>
`This will archive ${agents} agent(s), close ${terminals} terminal(s), and close ${tabs} tab(s). Any running process in a closed terminal will be stopped immediately.`,
agentsAndTerminals: ({ agents, terminals }) =>
`This will archive ${agents} agent(s) and close ${terminals} terminal(s). Any running process in a closed terminal will be stopped immediately.`,
terminalsAndTabs: ({ terminals, tabs }) =>
`This will close ${terminals} terminal(s) and close ${tabs} tab(s). Any running process in a closed terminal will be stopped immediately.`,
agentsAndTabs: ({ agents, tabs }) =>
`This will archive ${agents} agent(s) and close ${tabs} tab(s).`,
terminals: ({ terminals }) =>
`This will close ${terminals} terminal(s). Any running process in a closed terminal will be stopped immediately.`,
tabs: ({ tabs }) => `This will close ${tabs} tab(s).`,
agents: ({ agents }) => `This will archive ${agents} agent(s).`,
};
interface CloseWorkspaceTabWithCleanupInput {
tabId: string;
target?: WorkspaceTabDescriptor["target"];
@@ -43,27 +69,43 @@ export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkCl
return groups;
}
export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups): string {
export function buildBulkCloseConfirmationMessage(
input: BulkClosableTabGroups,
labels: BulkCloseConfirmationLabels = DEFAULT_BULK_CLOSE_CONFIRMATION_LABELS,
): string {
const { agentTabs, terminalTabs, otherTabs } = input;
if (agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0) {
return `This will archive ${agentTabs.length} agent(s), close ${terminalTabs.length} terminal(s), and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`;
return labels.all({
agents: agentTabs.length,
terminals: terminalTabs.length,
tabs: otherTabs.length,
});
}
if (agentTabs.length > 0 && terminalTabs.length > 0) {
return `This will archive ${agentTabs.length} agent(s) and close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`;
return labels.agentsAndTerminals({
agents: agentTabs.length,
terminals: terminalTabs.length,
});
}
if (terminalTabs.length > 0 && otherTabs.length > 0) {
return `This will close ${terminalTabs.length} terminal(s) and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`;
return labels.terminalsAndTabs({
terminals: terminalTabs.length,
tabs: otherTabs.length,
});
}
if (agentTabs.length > 0 && otherTabs.length > 0) {
return `This will archive ${agentTabs.length} agent(s) and close ${otherTabs.length} tab(s).`;
return labels.agentsAndTabs({
agents: agentTabs.length,
tabs: otherTabs.length,
});
}
if (terminalTabs.length > 0) {
return `This will close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`;
return labels.terminals({ terminals: terminalTabs.length });
}
if (otherTabs.length > 0) {
return `This will close ${otherTabs.length} tab(s).`;
return labels.tabs({ tabs: otherTabs.length });
}
return `This will archive ${agentTabs.length} agent(s).`;
return labels.agents({ agents: agentTabs.length });
}
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> {
@@ -81,7 +123,7 @@ export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput)
});
} else if (hasDestructiveTabs) {
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, {
error: new Error("Daemon client not available"),
error: new Error(i18n.t("common.errors.daemonClientUnavailable")),
});
}
@@ -32,6 +32,7 @@ import {
X,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { SortableInlineList } from "@/components/sortable-inline-list";
import type {
DraggableListDragHandleProps,
@@ -61,6 +62,7 @@ import {
buildWorkspaceDesktopTabActions,
type WorkspaceDesktopTabActions,
type WorkspaceTabMenuEntry,
type WorkspaceTabMenuLabels,
} from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { Theme } from "@/styles/theme";
@@ -223,20 +225,23 @@ interface WorkspaceDesktopTabsRowProps {
showPaneSplitActions?: boolean;
}
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
function getFallbackTabLabel(
tab: WorkspaceTabDescriptor,
labels: { newAgent: string; setup: string; terminal: string; agent: string },
): string {
if (tab.target.kind === "draft") {
return "New Agent";
return labels.newAgent;
}
if (tab.target.kind === "setup") {
return "Setup";
return labels.setup;
}
if (tab.target.kind === "terminal") {
return "Terminal";
return labels.terminal;
}
if (tab.target.kind === "file") {
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
}
return "Agent";
return labels.agent;
}
function useMiddleClickClose(onClose: () => void) {
@@ -533,6 +538,7 @@ export function WorkspaceDesktopTabsRow({
tabDropPreviewIndex = null,
showPaneSplitActions = true,
}: WorkspaceDesktopTabsRowProps) {
const { t } = useTranslation();
const newTabKeys = useShortcutKeys("workspace-tab-new");
const newTerminalKeys = useShortcutKeys("workspace-terminal-new");
const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
@@ -571,13 +577,38 @@ export function WorkspaceDesktopTabsRow({
[inlineAddButtonWidth, tabsActionsWidth],
);
const fallbackTabLabels = useMemo(
() => ({
newAgent: t("workspace.tabs.fallback.newAgent"),
setup: t("workspace.tabs.fallback.setup"),
terminal: t("workspace.tabs.fallback.terminal"),
agent: t("workspace.tabs.fallback.agent"),
}),
[t],
);
const tabMenuLabels = useMemo<WorkspaceTabMenuLabels>(
() => ({
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
copyAgentId: t("workspace.tabs.menu.copyAgentId"),
rename: t("workspace.tabs.menu.rename"),
closeAbove: t("workspace.tabs.menu.closeAbove"),
closeBelow: t("workspace.tabs.menu.closeBelow"),
closeLeft: t("workspace.tabs.menu.closeLeft"),
closeRight: t("workspace.tabs.menu.closeRight"),
closeOthers: t("workspace.tabs.menu.closeOthers"),
reloadAgent: t("workspace.tabs.menu.reloadAgent"),
reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"),
close: t("workspace.tabs.menu.close"),
}),
[t],
);
const tabLabelLengths = useMemo(
() =>
tabs.map((tab) => {
const label = getFallbackTabLabel(tab.tab);
const label = getFallbackTabLabel(tab.tab, fallbackTabLabels);
return label.length;
}),
[tabs],
[fallbackTabLabels, tabs],
);
const { layout } = useWorkspaceTabLayout({
@@ -664,6 +695,7 @@ export function WorkspaceDesktopTabsRow({
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
labels={tabMenuLabels}
dragHandleProps={dragHandleProps}
showDropIndicatorBefore={showDropIndicatorBefore}
showDropIndicatorAfter={showDropIndicatorAfter}
@@ -687,6 +719,7 @@ export function WorkspaceDesktopTabsRow({
onReloadAgent,
onRenameTab,
setHoveredCloseTabKey,
tabMenuLabels,
tabDropPreviewIndex,
tabs.length,
],
@@ -739,14 +772,14 @@ export function WorkspaceDesktopTabsRow({
testID="workspace-new-agent-tab"
onPress={handleCreateAgentTab}
accessibilityRole="button"
accessibilityLabel="New agent tab"
accessibilityLabel={t("workspace.tabs.actions.newAgent")}
style={newTabActionButtonStyle}
>
<ThemedSquarePen size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
<Text style={styles.newTabTooltipText}>{t("workspace.tabs.actions.newAgent")}</Text>
{newTabKeys ? (
<Shortcut chord={newTabKeys} style={styles.newTabTooltipShortcut} />
) : null}
@@ -760,7 +793,9 @@ export function WorkspaceDesktopTabsRow({
disabled={terminalDisabled}
accessibilityRole="button"
accessibilityLabel={
isWaitingOnTerminalReadiness ? "Preparing terminal tab" : "New terminal tab"
isWaitingOnTerminalReadiness
? t("workspace.tabs.actions.preparingTerminal")
: t("workspace.tabs.actions.newTerminal")
}
style={newTerminalActionButtonStyle}
>
@@ -769,7 +804,9 @@ export function WorkspaceDesktopTabsRow({
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>
{isWaitingOnTerminalReadiness ? "Preparing terminal..." : "New terminal tab"}
{isWaitingOnTerminalReadiness
? t("workspace.tabs.actions.preparingTerminalTooltip")
: t("workspace.tabs.actions.newTerminal")}
</Text>
{newTerminalKeys ? (
<Shortcut chord={newTerminalKeys} style={styles.newTabTooltipShortcut} />
@@ -783,14 +820,16 @@ export function WorkspaceDesktopTabsRow({
testID="workspace-new-browser"
onPress={handleCreateBrowser}
accessibilityRole="button"
accessibilityLabel="New browser tab"
accessibilityLabel={t("workspace.tabs.actions.newBrowser")}
style={newTabActionButtonStyle}
>
<ThemedGlobe size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New browser tab</Text>
<Text style={styles.newTabTooltipText}>
{t("workspace.tabs.actions.newBrowser")}
</Text>
</View>
</TooltipContent>
</Tooltip>
@@ -801,14 +840,16 @@ export function WorkspaceDesktopTabsRow({
<TooltipTrigger
onPress={onSplitRight}
accessibilityRole="button"
accessibilityLabel="Split pane right"
accessibilityLabel={t("workspace.tabs.actions.splitRight")}
style={newTabActionButtonStyle}
>
<ThemedColumns2 size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane right</Text>
<Text style={styles.newTabTooltipText}>
{t("workspace.tabs.actions.splitRight")}
</Text>
{splitRightKeys ? (
<Shortcut chord={splitRightKeys} style={styles.newTabTooltipShortcut} />
) : null}
@@ -819,14 +860,16 @@ export function WorkspaceDesktopTabsRow({
<TooltipTrigger
onPress={onSplitDown}
accessibilityRole="button"
accessibilityLabel="Split pane down"
accessibilityLabel={t("workspace.tabs.actions.splitDown")}
style={newTabActionButtonStyle}
>
<ThemedRows2 size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane down</Text>
<Text style={styles.newTabTooltipText}>
{t("workspace.tabs.actions.splitDown")}
</Text>
{splitDownKeys ? (
<Shortcut chord={splitDownKeys} style={styles.newTabTooltipShortcut} />
) : null}
@@ -862,6 +905,7 @@ function ResolvedDesktopTabChip({
setHoveredCloseTabKey,
onNavigateTab,
onCloseTab,
labels,
dragHandleProps,
showDropIndicatorBefore,
showDropIndicatorAfter,
@@ -886,10 +930,12 @@ function ResolvedDesktopTabChip({
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
labels: WorkspaceTabMenuLabels;
dragHandleProps: DraggableListDragHandleProps | undefined;
showDropIndicatorBefore: boolean;
showDropIndicatorAfter: boolean;
}) {
const { t } = useTranslation();
const resolvedTab = useMemo(
() =>
buildWorkspaceDesktopTabActions({
@@ -904,6 +950,7 @@ function ResolvedDesktopTabChip({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
labels,
}),
[
index,
@@ -914,6 +961,7 @@ function ResolvedDesktopTabChip({
onCloseTabsToRight,
onCopyAgentId,
onCopyResumeCommand,
labels,
onReloadAgent,
onRenameTab,
tabCount,
@@ -928,7 +976,9 @@ function ResolvedDesktopTabChip({
>
{(presentation) => {
const tooltipLabel =
presentation.titleState === "loading" ? "Loading agent title" : presentation.label;
presentation.titleState === "loading"
? t("workspace.tabs.loadingAgentTitle")
: presentation.label;
return (
<View style={styles.tabSlot}>
@@ -1,4 +1,5 @@
import { type ReactElement, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Pressable,
@@ -83,6 +84,7 @@ export function WorkspaceOpenInEditorButton({
activeFile,
hideLabels,
}: WorkspaceOpenInEditorButtonProps) {
const { t } = useTranslation();
const toast = useToast();
const isConnected = useHostRuntimeIsConnected(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
@@ -157,7 +159,9 @@ export function WorkspaceOpenInEditorButton({
const openMutation = useMutation({
mutationFn: (target: OpenTarget) => Promise.resolve(target.onOpen()),
onError: (error: unknown) => {
toast.error(error instanceof Error ? error.message : "Failed to open workspace");
toast.error(
error instanceof Error ? error.message : t("workspace.git.openInEditor.failedOpen"),
);
},
});
@@ -207,8 +211,13 @@ export function WorkspaceOpenInEditorButton({
accessibilityRole="button"
accessibilityLabel={
activeFileName
? `Open ${activeFileName} in ${primaryOption.label}`
: `Open workspace in ${primaryOption.label}`
? t("workspace.git.openInEditor.openFileIn", {
fileName: activeFileName,
target: primaryOption.label,
})
: t("workspace.git.openInEditor.openIn", {
target: primaryOption.label,
})
}
>
{openMutation.isPending ? (
@@ -220,7 +229,9 @@ export function WorkspaceOpenInEditorButton({
) : (
<View style={styles.splitButtonContent}>
{primaryOption.icon}
{!hideLabels && <Text style={styles.splitButtonText}>Open</Text>}
{!hideLabels && (
<Text style={styles.splitButtonText}>{t("workspace.git.openInEditor.open")}</Text>
)}
</View>
)}
</Pressable>
@@ -230,7 +241,7 @@ export function WorkspaceOpenInEditorButton({
testID="workspace-open-in-editor-caret"
style={caretTriggerStyle}
accessibilityRole="button"
accessibilityLabel="Choose editor"
accessibilityLabel={t("workspace.git.openInEditor.chooseEditor")}
>
<ThemedChevronDown size={16} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
@@ -1,5 +1,6 @@
import { Text, View } from "react-native";
import { ArrowLeftToLine, RotateCw, Settings } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -43,24 +44,26 @@ export function renderWorkspaceRouteGate(input: {
function getWorkspaceHostStateTitle(
state: Extract<WorkspaceRouteState, { kind: "unreachable" }>,
t: ReturnType<typeof useTranslation>["t"],
): string {
if (state.connectionStatus === "connecting" || state.connectionStatus === "idle") {
return "Connecting";
return t("workspace.route.connecting");
}
if (state.connectionStatus === "offline") {
return `${state.hostName} is offline`;
return t("workspace.route.hostOffline", { hostName: state.hostName });
}
return `Cannot reach ${state.hostName}`;
return t("workspace.route.cannotReachHost", { hostName: state.hostName });
}
function WorkspaceConnecting({ hostName }: { hostName: string }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
return (
<View style={styles.emptyState}>
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
<View style={styles.textStack}>
<Text style={styles.title}>Loading workspace</Text>
<Text style={styles.title}>{t("workspace.route.loading")}</Text>
<Text style={styles.description}>{hostName}</Text>
</View>
</View>
@@ -77,6 +80,7 @@ function WorkspaceUnreachable({
onManageHost: () => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const canRetry = state.connectionStatus === "offline" || state.connectionStatus === "error";
return (
@@ -85,11 +89,13 @@ function WorkspaceUnreachable({
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
) : null}
<View style={styles.textStack}>
<Text style={styles.title}>{getWorkspaceHostStateTitle(state)}</Text>
<Text style={styles.title}>{getWorkspaceHostStateTitle(state, t)}</Text>
<Text style={styles.description}>
{state.connectionStatus === "connecting" || state.connectionStatus === "idle"
? state.hostName
: `Host status: ${formatConnectionStatus(state.connectionStatus)}`}
: t("workspace.route.hostStatus", {
status: formatConnectionStatus(state.connectionStatus),
})}
</Text>
{state.lastError ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -107,10 +113,10 @@ function WorkspaceUnreachable({
{canRetry ? (
<View style={styles.actions}>
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
Retry
{t("common.actions.retry")}
</Button>
<Button size="sm" variant="outline" leftIcon={Settings} onPress={onManageHost}>
Manage host
{t("workspace.route.manageHost")}
</Button>
</View>
) : null}
@@ -119,15 +125,17 @@ function WorkspaceUnreachable({
}
function WorkspaceMissing({ hostName, onDismiss }: { hostName: string; onDismiss: () => void }) {
const { t } = useTranslation();
return (
<View style={styles.emptyState}>
<View style={styles.textStack}>
<Text style={styles.title}>Workspace not found</Text>
<Text style={styles.title}>{t("workspace.route.missing")}</Text>
<Text style={styles.description}>{hostName}</Text>
</View>
<View style={styles.actions}>
<Button size="sm" variant="default" leftIcon={ArrowLeftToLine} onPress={onDismiss}>
Back
{t("common.actions.back")}
</Button>
</View>
</View>
@@ -14,6 +14,7 @@ import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter, type Href } from "expo-router";
import * as Clipboard from "expo-clipboard";
import { useTranslation } from "react-i18next";
import { DiffStat } from "@/components/diff-stat";
import {
CopyX,
@@ -129,6 +130,7 @@ import {
import {
buildWorkspaceTabMenuEntries,
type WorkspaceTabMenuEntry,
type WorkspaceTabMenuLabels,
} from "@/screens/workspace/workspace-tab-menu";
import { useDesktopBrowserNewTabRequests } from "@/browser/new-tab-requests";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
@@ -160,6 +162,7 @@ import { WorkspaceFocusProvider } from "@/workspace/focus";
import { shouldSeedEmptyWorkspaceDraft } from "@/screens/workspace/workspace-empty-draft-seed";
import {
buildBulkCloseConfirmationMessage,
type BulkCloseConfirmationLabels,
classifyBulkClosableTabs,
closeBulkWorkspaceTabs,
} from "@/screens/workspace/workspace-bulk-close";
@@ -295,40 +298,58 @@ function useSyncWorkspaceActiveBrowser(input: {
}, [desktopActiveBrowserId]);
}
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
function getFallbackTabOptionLabel(
tab: WorkspaceTabDescriptor,
labels: {
newAgent: string;
setup: string;
terminal: string;
browser: string;
agent: string;
},
): string {
if (tab.target.kind === "draft") {
return "New Agent";
return labels.newAgent;
}
if (tab.target.kind === "setup") {
return "Setup";
return labels.setup;
}
if (tab.target.kind === "terminal") {
return "Terminal";
return labels.terminal;
}
if (tab.target.kind === "browser") {
return "Browser";
return labels.browser;
}
if (tab.target.kind === "file") {
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
}
return "Agent";
return labels.agent;
}
function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
function getFallbackTabOptionDescription(
tab: WorkspaceTabDescriptor,
labels: {
newAgent: string;
workspaceSetup: string;
agent: string;
terminal: string;
browser: string;
},
): string {
if (tab.target.kind === "draft") {
return "New Agent";
return labels.newAgent;
}
if (tab.target.kind === "setup") {
return "Workspace setup";
return labels.workspaceSetup;
}
if (tab.target.kind === "agent") {
return "Agent";
return labels.agent;
}
if (tab.target.kind === "terminal") {
return "Terminal";
return labels.terminal;
}
if (tab.target.kind === "browser") {
return "Browser";
return labels.browser;
}
return tab.target.path;
}
@@ -383,6 +404,7 @@ function ResolvedMobileActiveTabTrigger({
normalizedServerId: string;
normalizedWorkspaceId: string;
}) {
const { t } = useTranslation();
return (
<WorkspaceTabPresentationResolver
tab={activeTab}
@@ -396,7 +418,9 @@ function ResolvedMobileActiveTabTrigger({
</View>
<Text style={styles.switcherTriggerText} numberOfLines={1}>
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
{presentation.titleState === "loading"
? t("workspace.tabs.loading")
: presentation.label}
</Text>
</>
)}
@@ -411,13 +435,17 @@ function WorkspaceDocumentTitleEffect({
label: string;
titleState: "ready" | "loading";
}) {
const { t } = useTranslation();
useEffect(() => {
if (isNative || typeof document === "undefined") {
return;
}
const resolvedLabel = label.trim();
document.title = titleState === "loading" ? "Loading..." : resolvedLabel || "Workspace";
}, [label, titleState]);
document.title =
titleState === "loading"
? t("workspace.tabs.loading")
: resolvedLabel || t("workspace.tabs.fallback.workspace");
}, [label, titleState, t]);
return null;
}
@@ -444,12 +472,13 @@ function MobileTabTrailingAccessory({
presentationLabel: string;
menuEntries: WorkspaceTabMenuEntry[];
}) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger
testID={`${menuTestIDBase}-trigger`}
accessibilityRole="button"
accessibilityLabel={`Open menu for ${presentationLabel}`}
accessibilityLabel={t("workspace.tabs.menu.openFor", { label: presentationLabel })}
hitSlop={8}
style={mobileTabMenuTriggerStyle}
>
@@ -547,6 +576,23 @@ function MobileWorkspaceTabOption({
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}) {
const { t } = useTranslation();
const tabMenuLabels = useMemo<WorkspaceTabMenuLabels>(
() => ({
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
copyAgentId: t("workspace.tabs.menu.copyAgentId"),
rename: t("workspace.tabs.menu.rename"),
closeAbove: t("workspace.tabs.menu.closeAbove"),
closeBelow: t("workspace.tabs.menu.closeBelow"),
closeLeft: t("workspace.tabs.menu.closeLeft"),
closeRight: t("workspace.tabs.menu.closeRight"),
closeOthers: t("workspace.tabs.menu.closeOthers"),
reloadAgent: t("workspace.tabs.menu.reloadAgent"),
reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"),
close: t("workspace.tabs.menu.close"),
}),
[t],
);
const menuTestIDBase = `workspace-tab-menu-${buildDeterministicWorkspaceTabId(tab.target)}`;
const menuEntries = buildWorkspaceTabMenuEntries({
surface: "mobile",
@@ -562,9 +608,20 @@ function MobileWorkspaceTabOption({
onCloseTabsBefore: onCloseTabsAbove,
onCloseTabsAfter: onCloseTabsBelow,
onCloseOtherTabs,
labels: tabMenuLabels,
});
const fallbackLabel = getFallbackTabOptionLabel(tab);
const fallbackLabels = useMemo(
() => ({
newAgent: t("workspace.tabs.fallback.newAgent"),
setup: t("workspace.tabs.fallback.setup"),
terminal: t("workspace.tabs.fallback.terminal"),
browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"),
}),
[t],
);
const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels);
const trailingAccessory = useMemo(
() => (
<MobileTabTrailingAccessory
@@ -618,6 +675,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
onCloseTabsBelow,
onCloseOtherTabs,
}: MobileWorkspaceTabSwitcherProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const tabIndexByKey = useMemo(() => {
@@ -697,7 +755,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
ref={anchorRef}
testID="workspace-tab-switcher-trigger"
accessibilityRole="button"
accessibilityLabel={`Switch tabs (${tabs.length} open)`}
accessibilityLabel={t("workspace.tabs.switcher.trigger", { count: tabs.length })}
style={switcherTriggerStyle}
onPress={handleOpenSwitcher}
>
@@ -716,8 +774,8 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
value={activeTabKey}
onSelect={onSelectSwitcherTab}
searchable={false}
title="Switch tab"
searchPlaceholder="Search tabs"
title={t("workspace.tabs.switcher.title")}
searchPlaceholder={t("workspace.tabs.switcher.searchPlaceholder")}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={anchorRef}
@@ -927,6 +985,7 @@ function WorkspaceHeaderMenu({
onCopyBranchName,
onOpenSetupTab,
}: WorkspaceHeaderMenuProps) {
const { t } = useTranslation();
const renderTriggerIcon = useCallback(
({ hovered, open }: { hovered: boolean; open: boolean }) => (
<WorkspaceHeaderMenuTriggerIcon hovered={hovered} open={open} isMobile={isMobile} />
@@ -940,7 +999,7 @@ function WorkspaceHeaderMenu({
testID="workspace-header-menu-trigger"
style={isMobile ? styles.compactHeaderActionButton : styles.headerActionButton}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
accessibilityLabel={t("workspace.header.actions.workspaceActions")}
>
{renderTriggerIcon}
</DropdownMenuTrigger>
@@ -950,7 +1009,7 @@ function WorkspaceHeaderMenu({
leading={menuNewAgentIcon}
onSelect={onCreateDraftTab}
>
New agent
{t("workspace.header.actions.newAgent")}
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-header-new-terminal"
@@ -958,7 +1017,7 @@ function WorkspaceHeaderMenu({
disabled={createTerminalDisabled}
onSelect={onCreateTerminal}
>
New terminal
{t("workspace.header.actions.newTerminal")}
</DropdownMenuItem>
{showCreateBrowserTab ? (
<DropdownMenuItem
@@ -966,7 +1025,7 @@ function WorkspaceHeaderMenu({
leading={menuNewBrowserIcon}
onSelect={onCreateBrowser}
>
New browser tab
{t("workspace.header.actions.newBrowser")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
@@ -975,7 +1034,7 @@ function WorkspaceHeaderMenu({
disabled={importAgentDisabled}
onSelect={onOpenImportSheet}
>
Import session
{t("workspace.header.actions.importSession")}
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-header-copy-path"
@@ -983,7 +1042,7 @@ function WorkspaceHeaderMenu({
disabled={!isAbsolutePath(normalizedWorkspaceId)}
onSelect={onCopyWorkspacePath}
>
Copy workspace path
{t("workspace.header.actions.copyPath")}
</DropdownMenuItem>
{currentBranchName ? (
<DropdownMenuItem
@@ -991,7 +1050,7 @@ function WorkspaceHeaderMenu({
leading={menuCopyIcon}
onSelect={onCopyBranchName}
>
Copy branch name
{t("workspace.header.actions.copyBranchName")}
</DropdownMenuItem>
) : null}
{showWorkspaceSetup ? (
@@ -1002,7 +1061,7 @@ function WorkspaceHeaderMenu({
leading={menuSettingsIcon}
onSelect={onOpenSetupTab}
>
Show setup
{t("workspace.header.actions.showSetup")}
</DropdownMenuItem>
</>
) : null}
@@ -1452,6 +1511,10 @@ interface WorkspaceTerminalTabActionsInput {
persistenceKey: string | null;
focusWorkspacePane: (workspaceKey: string, paneId: string) => void;
openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
labels: {
workspacePathUnavailable: string;
terminalQueued: string;
};
toast: {
error: (message: string) => void;
show: (message: string) => void;
@@ -1469,6 +1532,7 @@ function useWorkspaceTerminalTabActions({
persistenceKey,
focusWorkspacePane,
openWorkspaceTabFocused,
labels,
toast,
}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions {
const handleTerminalCreated = useCallback(
@@ -1493,11 +1557,11 @@ function useWorkspaceTerminalTabActions({
[openWorkspaceTabFocused, persistenceKey],
);
const handleWorkspacePathUnavailable = useCallback(() => {
toast.error("Workspace path is not available yet");
}, [toast]);
toast.error(labels.workspacePathUnavailable);
}, [labels.workspacePathUnavailable, toast]);
const handleTerminalCreateQueued = useCallback(() => {
toast.show("Preparing workspace, opening terminal when ready...");
}, [toast]);
toast.show(labels.terminalQueued);
}, [labels.terminalQueued, toast]);
return {
handleTerminalCreated,
@@ -1515,6 +1579,7 @@ function useWorkspaceCheckoutStatus(input: {
normalizedWorkspaceId: string;
workspaceDirectory: string | null;
}) {
const { t } = useTranslation();
const isCheckoutQueryEnabled = useMemo(
() =>
canCreateWorkspaceTerminal({
@@ -1533,7 +1598,7 @@ function useWorkspaceCheckoutStatus(input: {
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!input.client || !input.workspaceDirectory) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return await input.client.getCheckoutStatus(input.workspaceDirectory);
},
@@ -1555,6 +1620,7 @@ function WorkspaceScreenContent({
workspaceId,
isRouteFocused,
}: WorkspaceScreenContentProps) {
const { t } = useTranslation();
const _insets = useSafeAreaInsets();
const toast = useToast();
const isMobile = useIsCompactFormFactor();
@@ -1642,6 +1708,10 @@ function WorkspaceScreenContent({
persistenceKey,
focusWorkspacePane,
openWorkspaceTabFocused,
labels: {
workspacePathUnavailable: t("workspace.header.toasts.workspacePathUnavailable"),
terminalQueued: t("workspace.header.toasts.terminalQueued"),
},
toast,
});
const queryClient = useQueryClient();
@@ -2280,17 +2350,58 @@ function WorkspaceScreenContent({
}
return map;
}, [uiTabs]);
const bulkCloseConfirmationLabels = useMemo<BulkCloseConfirmationLabels>(
() => ({
all: ({ agents, terminals: terminalCount, tabs: tabCount }) =>
t("workspace.tabs.confirmations.bulk.all", {
agents,
terminals: terminalCount,
tabs: tabCount,
}),
agentsAndTerminals: ({ agents, terminals: terminalCount }) =>
t("workspace.tabs.confirmations.bulk.agentsAndTerminals", {
agents,
terminals: terminalCount,
}),
terminalsAndTabs: ({ terminals: terminalCount, tabs: tabCount }) =>
t("workspace.tabs.confirmations.bulk.terminalsAndTabs", {
terminals: terminalCount,
tabs: tabCount,
}),
agentsAndTabs: ({ agents, tabs: tabCount }) =>
t("workspace.tabs.confirmations.bulk.agentsAndTabs", { agents, tabs: tabCount }),
terminals: ({ terminals: terminalCount }) =>
t("workspace.tabs.confirmations.bulk.terminals", { terminals: terminalCount }),
tabs: ({ tabs: tabCount }) => t("workspace.tabs.confirmations.bulk.tabs", { tabs: tabCount }),
agents: ({ agents }) => t("workspace.tabs.confirmations.bulk.agents", { agents }),
}),
[t],
);
const explorerToggleLabel = isExplorerOpen
? t("workspace.tabs.explorer.close")
: t("workspace.tabs.explorer.open");
const activeTabKey = useMemo(() => activeTabId ?? "", [activeTabId]);
const tabFallbackLabels = useMemo(
() => ({
newAgent: t("workspace.tabs.fallback.newAgent"),
setup: t("workspace.tabs.fallback.setup"),
workspaceSetup: t("workspace.tabs.fallback.workspaceSetup"),
terminal: t("workspace.tabs.fallback.terminal"),
browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"),
}),
[t],
);
const tabSwitcherOptions = useMemo(
() =>
tabs.map((tab) => ({
id: tab.key,
label: getFallbackTabOptionLabel(tab),
description: getFallbackTabOptionDescription(tab),
label: getFallbackTabOptionLabel(tab, tabFallbackLabels),
description: getFallbackTabOptionDescription(tab, tabFallbackLabels),
})),
[tabs],
[tabFallbackLabels, tabs],
);
const handleCreateDraftTab = useCallback(
@@ -2366,10 +2477,10 @@ function WorkspaceScreenContent({
const { tabId, terminalId } = input;
await closeTab(tabId, async () => {
const confirmed = await confirmDialog({
title: "Close terminal?",
message: "Any running process in this terminal will be stopped immediately.",
confirmLabel: "Close",
cancelLabel: "Cancel",
title: t("workspace.tabs.confirmations.closeTerminalTitle"),
message: t("workspace.tabs.confirmations.closeTerminalMessage"),
confirmLabel: t("workspace.tabs.confirmations.close"),
cancelLabel: t("workspace.tabs.confirmations.cancel"),
destructive: true,
});
if (!confirmed) {
@@ -2395,6 +2506,7 @@ function WorkspaceScreenContent({
killTerminalAsync,
persistenceKey,
removeTerminalFromCache,
t,
],
);
@@ -2413,11 +2525,10 @@ function WorkspaceScreenContent({
if (isRunning && closePolicy.kind === "archive-on-close") {
const confirmed = await confirmDialog({
title: "Archive running agent?",
message:
"This agent is still running. Archiving it will stop the agent and close the tab.",
confirmLabel: "Archive",
cancelLabel: "Cancel",
title: t("workspace.tabs.confirmations.archiveRunningAgentTitle"),
message: t("workspace.tabs.confirmations.archiveRunningAgentMessage"),
confirmLabel: t("workspace.tabs.confirmations.archive"),
cancelLabel: t("workspace.tabs.confirmations.cancel"),
destructive: true,
});
if (!confirmed) {
@@ -2441,7 +2552,7 @@ function WorkspaceScreenContent({
void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {});
});
},
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey, t],
);
const handleCloseDraftOrFileTab = useCallback(
@@ -2481,12 +2592,12 @@ function WorkspaceScreenContent({
if (!agentId) return;
try {
await Clipboard.setStringAsync(agentId);
toast.copied("Agent ID");
toast.copied(t("workspace.tabs.toasts.agentIdCopiedLabel"));
} catch {
toast.error("Copy failed");
toast.error(t("workspace.tabs.toasts.copyFailed"));
}
},
[toast],
[toast, t],
);
const handleCopyResumeCommand = useCallback(
@@ -2497,7 +2608,7 @@ function WorkspaceScreenContent({
const providerSessionId =
agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null;
if (!agent || !providerSessionId) {
toast.error("Resume ID not available");
toast.error(t("workspace.tabs.toasts.resumeIdUnavailable"));
return;
}
@@ -2508,27 +2619,27 @@ function WorkspaceScreenContent({
sessionId: providerSessionId,
}) ?? null;
if (!command) {
toast.error("Resume command not available");
toast.error(t("workspace.tabs.toasts.resumeCommandUnavailable"));
return;
}
try {
await Clipboard.setStringAsync(command);
toast.copied("resume command");
toast.copied(t("workspace.tabs.toasts.resumeCommandCopiedLabel"));
} catch {
toast.error("Copy failed");
toast.error(t("workspace.tabs.toasts.copyFailed"));
}
},
[normalizedServerId, toast],
[normalizedServerId, toast, t],
);
const handleReloadAgent = useCallback(
async (agentId: string) => {
if (!client || !isConnected) {
toast.error("Host is not connected");
toast.error(t("workspace.terminal.hostDisconnected"));
return;
}
toast.show("Reloading agent…", { durationMs: null });
toast.show(t("workspace.tabs.toasts.reloadingAgent"), { durationMs: null });
try {
await client.refreshAgent(agentId);
// Send the existing cursor so the server detects the new epoch and
@@ -2544,41 +2655,43 @@ function WorkspaceScreenContent({
? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } }
: {}),
});
toast.show("Reloaded agent", { variant: "success" });
toast.show(t("workspace.tabs.toasts.reloadedAgent"), { variant: "success" });
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to reload agent");
toast.error(
error instanceof Error ? error.message : t("workspace.tabs.toasts.failedToReloadAgent"),
);
}
},
[client, isConnected, normalizedServerId, toast],
[client, isConnected, normalizedServerId, toast, t],
);
const handleCopyWorkspacePath = useCallback(async () => {
if (!workspaceDirectory) {
toast.error("Workspace path not available");
toast.error(t("workspace.header.toasts.workspacePathUnavailable"));
return;
}
try {
await Clipboard.setStringAsync(workspaceDirectory);
toast.copied("Workspace path");
toast.copied(t("workspace.header.toasts.workspacePathCopiedLabel"));
} catch {
toast.error("Copy failed");
toast.error(t("workspace.tabs.toasts.copyFailed"));
}
}, [toast, workspaceDirectory]);
}, [toast, workspaceDirectory, t]);
const handleCopyBranchName = useCallback(async () => {
if (!currentBranchName) {
toast.error("Branch name not available");
toast.error(t("workspace.header.toasts.branchNameUnavailable"));
return;
}
try {
await Clipboard.setStringAsync(currentBranchName);
toast.copied("Branch name");
toast.copied(t("workspace.header.toasts.branchNameCopiedLabel"));
} catch {
toast.error("Copy failed");
toast.error(t("workspace.tabs.toasts.copyFailed"));
}
}, [currentBranchName, toast]);
}, [currentBranchName, toast, t]);
const handleOpenSetupTab = useCallback(() => {
if (!persistenceKey) {
@@ -2604,9 +2717,9 @@ function WorkspaceScreenContent({
const groups = classifyBulkClosableTabs(tabsToClose);
const confirmed = await confirmDialog({
title,
message: buildBulkCloseConfirmationMessage(groups),
confirmLabel: "Close",
cancelLabel: "Cancel",
message: buildBulkCloseConfirmationMessage(groups, bulkCloseConfirmationLabels),
confirmLabel: t("workspace.tabs.confirmations.close"),
cancelLabel: t("workspace.tabs.confirmations.cancel"),
destructive: true,
});
if (!confirmed) {
@@ -2632,7 +2745,14 @@ function WorkspaceScreenContent({
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
[client, closeTab, closeWorkspaceTabWithCleanup, persistenceKey],
[
bulkCloseConfirmationLabels,
client,
closeTab,
closeWorkspaceTabWithCleanup,
persistenceKey,
t,
],
);
const handleCloseTabsToLeftInPane = useCallback(
@@ -2643,11 +2763,11 @@ function WorkspaceScreenContent({
}
await handleBulkCloseTabs({
tabsToClose: paneTabs.slice(0, index),
title: "Close tabs to the left?",
title: t("workspace.tabs.confirmations.closeTabsLeftTitle"),
logLabel: "to the left",
});
},
[handleBulkCloseTabs],
[handleBulkCloseTabs, t],
);
const handleCloseTabsToLeft = useCallback(
@@ -2665,11 +2785,11 @@ function WorkspaceScreenContent({
}
await handleBulkCloseTabs({
tabsToClose: paneTabs.slice(index + 1),
title: "Close tabs to the right?",
title: t("workspace.tabs.confirmations.closeTabsRightTitle"),
logLabel: "to the right",
});
},
[handleBulkCloseTabs],
[handleBulkCloseTabs, t],
);
const handleCloseTabsToRight = useCallback(
@@ -2684,11 +2804,11 @@ function WorkspaceScreenContent({
const tabsToClose = paneTabs.filter((tab) => tab.tabId !== tabId);
await handleBulkCloseTabs({
tabsToClose,
title: "Close other tabs?",
title: t("workspace.tabs.confirmations.closeOtherTabsTitle"),
logLabel: "from close other tabs",
});
},
[handleBulkCloseTabs],
[handleBulkCloseTabs, t],
);
const handleCloseOtherTabs = useCallback(
@@ -3084,13 +3204,16 @@ function WorkspaceScreenContent({
[focusedPaneId, handleReorderTabsInPane],
);
const renderSplitPaneEmptyState = useCallback(function renderSplitPaneEmptyState() {
return (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>No tabs in this pane.</Text>
</View>
);
}, []);
const renderSplitPaneEmptyState = useCallback(
function renderSplitPaneEmptyState() {
return (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>{t("workspace.tabs.emptyPane")}</Text>
</View>
);
},
[t],
);
const containerStyle = containerWithWorkspaceBackgroundStyle;
@@ -3147,7 +3270,7 @@ function WorkspaceScreenContent({
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
style={explorerToggleStyle}
>
@@ -3175,7 +3298,9 @@ function WorkspaceScreenContent({
offset={8}
>
<View style={styles.explorerTooltipRow}>
<Text style={styles.explorerTooltipText}>Toggle explorer</Text>
<Text style={styles.explorerTooltipText}>
{t("workspace.tabs.explorer.toggle")}
</Text>
<Shortcut keys={EXPLORER_TOGGLE_KEYS} style={styles.explorerTooltipShortcut} />
</View>
</TooltipContent>
@@ -3186,13 +3311,13 @@ function WorkspaceScreenContent({
<HeaderToggleButton
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
tooltipLabel="Toggle explorer"
tooltipLabel={t("workspace.tabs.explorer.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
tooltipSide="left"
style={styles.compactHeaderActionButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
>
{({ hovered }) => {
@@ -3206,13 +3331,13 @@ function WorkspaceScreenContent({
<HeaderToggleButton
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
tooltipLabel="Toggle explorer"
tooltipLabel={t("workspace.tabs.explorer.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
tooltipSide="left"
style={styles.headerActionButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
>
{({ hovered }) => {
@@ -3247,8 +3372,10 @@ function WorkspaceScreenContent({
isGitCheckout,
handleToggleExplorer,
isExplorerOpen,
explorerToggleLabel,
explorerToggleAccessibilityState,
explorerToggleStyle,
t,
],
);
@@ -1,6 +1,7 @@
/**
* @vitest-environment jsdom
*/
import { i18n as testI18n } from "@/i18n/i18next";
import React, { type ReactElement } from "react";
import { act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -9,6 +10,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot } from "react-dom/client";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
void testI18n;
const { theme, startWorkspaceScriptMock } = vi.hoisted(() => {
const hoistedTheme = {
spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12 },
@@ -4,6 +4,7 @@ import { Pressable, Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { ChevronDown, ExternalLink, Globe, Play, SquareTerminal } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
@@ -169,6 +170,7 @@ function HostLinkChildren({ hovered, disabled, label }: HostLinkChildrenProps):
}
function HostLinkRow({ label, url, scriptName, onOpenInBrowserTab }: HostLinkProps): ReactElement {
const { t } = useTranslation();
const disabled = !url;
const closeMenu = useDropdownMenuClose();
@@ -192,7 +194,7 @@ function HostLinkRow({ label, url, scriptName, onOpenInBrowserTab }: HostLinkPro
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Open ${scriptName} at ${label}`}
accessibilityLabel={t("workspace.scripts.accessibility.openAt", { scriptName, label })}
disabled={disabled}
hitSlop={2}
onPress={handlePress}
@@ -204,10 +206,11 @@ function HostLinkRow({ label, url, scriptName, onOpenInBrowserTab }: HostLinkPro
}
function ExitCodeBadge({ code }: { code: number }): ReactElement {
const { t } = useTranslation();
const exitTextStyle = code === 0 ? styles.exitBadgeText : exitBadgeTextErrorStyle;
return (
<View style={styles.exitBadge}>
<Text style={exitTextStyle}>exit {code}</Text>
<Text style={exitTextStyle}>{t("workspace.scripts.states.exitCode", { code })}</Text>
</View>
);
}
@@ -257,6 +260,7 @@ function ScriptRow({
onViewTerminal,
onOpenUrlInBrowserTab,
}: ScriptRowProps): ReactElement {
const { t } = useTranslation();
const isRunning = script.lifecycle === "running";
const isService = (script.type ?? "service") === "service";
const exitCode = script.exitCode ?? null;
@@ -309,21 +313,25 @@ function ScriptRow({
if (isRunning && liveTerminalId) {
primaryAction = (
<ScriptActionButton
accessibilityLabel={`View ${script.scriptName} terminal`}
accessibilityLabel={t("workspace.scripts.accessibility.viewTerminal", {
scriptName: script.scriptName,
})}
testID={`workspace-scripts-view-${script.scriptName}`}
icon="view"
label="View"
label={t("workspace.scripts.actions.view")}
onPress={handleView}
/>
);
} else if (!isRunning) {
primaryAction = (
<ScriptActionButton
accessibilityLabel={`Run ${script.scriptName} script`}
accessibilityLabel={t("workspace.scripts.accessibility.runScript", {
scriptName: script.scriptName,
})}
testID={`workspace-scripts-start-${script.scriptName}`}
disabled={isStartPending}
icon="start"
label="Run"
label={t("workspace.scripts.actions.run")}
onPress={handleRun}
/>
);
@@ -332,7 +340,9 @@ function ScriptRow({
return (
<View
testID={`workspace-scripts-item-${script.scriptName}`}
accessibilityLabel={`${script.scriptName} script`}
accessibilityLabel={t("workspace.scripts.accessibility.script", {
scriptName: script.scriptName,
})}
style={styles.scriptItem}
>
<View style={styles.scriptHeader}>
@@ -372,6 +382,7 @@ export function WorkspaceScriptsButton({
hideLabels,
presentation = "split",
}: WorkspaceScriptsButtonProps): ReactElement | null {
const { t } = useTranslation();
const toast = useToast();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
@@ -380,7 +391,7 @@ export function WorkspaceScriptsButton({
const startScriptMutation = useMutation({
mutationFn: async (scriptName: string) => {
if (!client) {
throw new Error("Daemon client not available");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
const result = await client.startWorkspaceScript(workspaceId, scriptName);
if (result.error) {
@@ -389,9 +400,14 @@ export function WorkspaceScriptsButton({
return result;
},
onError: (error, scriptName) => {
toast.show(error instanceof Error ? error.message : `Failed to start ${scriptName}`, {
variant: "error",
});
toast.show(
error instanceof Error
? error.message
: t("workspace.scripts.states.startFailed", { scriptName }),
{
variant: "error",
},
);
},
onSuccess: (result) => {
if (result.terminalId) {
@@ -432,7 +448,7 @@ export function WorkspaceScriptsButton({
testID="workspace-scripts-button"
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel="Workspace scripts"
accessibilityLabel={t("workspace.scripts.accessibility.trigger")}
>
<View style={styles.splitButtonContent}>
<ThemedPlay
@@ -440,7 +456,9 @@ export function WorkspaceScriptsButton({
uniProps={triggerPlayMapping}
{...triggerPlayProps}
/>
{!hideLabels && <Text style={styles.splitButtonText}>Scripts</Text>}
{!hideLabels && (
<Text style={styles.splitButtonText}>{t("workspace.scripts.title")}</Text>
)}
{presentation === "split" ? (
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
) : null}
@@ -1,9 +1,38 @@
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { i18n } from "@/i18n/i18next";
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity";
export type WorkspaceTabMenuSurface = "desktop" | "mobile";
export interface WorkspaceTabMenuLabels {
copyResumeCommand: string;
copyAgentId: string;
rename: string;
closeAbove: string;
closeBelow: string;
closeLeft: string;
closeRight: string;
closeOthers: string;
reloadAgent: string;
reloadAgentTooltip: string;
close: string;
}
export const DEFAULT_WORKSPACE_TAB_MENU_LABELS: WorkspaceTabMenuLabels = {
copyResumeCommand: i18n.t("workspace.tabs.menu.copyResumeCommand"),
copyAgentId: i18n.t("workspace.tabs.menu.copyAgentId"),
rename: i18n.t("workspace.tabs.menu.rename"),
closeAbove: i18n.t("workspace.tabs.menu.closeAbove"),
closeBelow: i18n.t("workspace.tabs.menu.closeBelow"),
closeLeft: i18n.t("workspace.tabs.menu.closeLeft"),
closeRight: i18n.t("workspace.tabs.menu.closeRight"),
closeOthers: i18n.t("workspace.tabs.menu.closeOthers"),
reloadAgent: i18n.t("workspace.tabs.menu.reloadAgent"),
reloadAgentTooltip: i18n.t("workspace.tabs.menu.reloadAgentTooltip"),
close: i18n.t("workspace.tabs.menu.close"),
};
export type WorkspaceTabMenuEntry =
| {
kind: "item";
@@ -43,6 +72,7 @@ interface BuildWorkspaceTabMenuEntriesInput {
onCloseTabsBefore: (tabId: string) => Promise<void> | void;
onCloseTabsAfter: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
labels?: WorkspaceTabMenuLabels;
}
interface BuildWorkspaceDesktopTabActionsInput {
@@ -57,6 +87,7 @@ interface BuildWorkspaceDesktopTabActionsInput {
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
labels?: WorkspaceTabMenuLabels;
}
export interface WorkspaceDesktopTabActions {
@@ -65,12 +96,18 @@ export interface WorkspaceDesktopTabActions {
closeButtonTestId: string;
}
function buildCloseBeforeLabel(surface: WorkspaceTabMenuSurface): string {
return surface === "mobile" ? "Close tabs above" : "Close to the left";
function buildCloseBeforeLabel(
surface: WorkspaceTabMenuSurface,
labels: WorkspaceTabMenuLabels,
): string {
return surface === "mobile" ? labels.closeAbove : labels.closeLeft;
}
function buildCloseAfterLabel(surface: WorkspaceTabMenuSurface): string {
return surface === "mobile" ? "Close tabs below" : "Close to the right";
function buildCloseAfterLabel(
surface: WorkspaceTabMenuSurface,
labels: WorkspaceTabMenuLabels,
): string {
return surface === "mobile" ? labels.closeBelow : labels.closeRight;
}
function buildCloseBeforeTestIDSuffix(surface: WorkspaceTabMenuSurface): string {
@@ -118,6 +155,7 @@ export function buildWorkspaceTabMenuEntries(
onCloseTabsAfter,
onCloseOtherTabs,
} = input;
const labels = input.labels ?? DEFAULT_WORKSPACE_TAB_MENU_LABELS;
const isFirstTab = index === 0;
const isLastTab = index === tabCount - 1;
const isOnlyTab = tabCount <= 1;
@@ -128,7 +166,7 @@ export function buildWorkspaceTabMenuEntries(
entries.push({
kind: "item",
key: "copy-resume-command",
label: "Copy resume command",
label: labels.copyResumeCommand,
icon: "copy",
testID: `${menuTestIDBase}-copy-resume-command`,
onSelect: () => {
@@ -138,7 +176,7 @@ export function buildWorkspaceTabMenuEntries(
entries.push({
kind: "item",
key: "copy-agent-id",
label: "Copy agent id",
label: labels.copyAgentId,
icon: "copy",
hint: agentId.slice(0, 7),
testID: `${menuTestIDBase}-copy-agent-id`,
@@ -152,7 +190,7 @@ export function buildWorkspaceTabMenuEntries(
entries.push({
kind: "item",
key: "rename",
label: "Rename",
label: labels.rename,
icon: "pencil",
testID: `${menuTestIDBase}-rename`,
onSelect: () => {
@@ -168,7 +206,7 @@ export function buildWorkspaceTabMenuEntries(
entries.push({
kind: "item",
key: "close-before",
label: buildCloseBeforeLabel(surface),
label: buildCloseBeforeLabel(surface, labels),
icon: "arrow-left-to-line",
disabled: isFirstTab,
testID: `${menuTestIDBase}-${buildCloseBeforeTestIDSuffix(surface)}`,
@@ -179,7 +217,7 @@ export function buildWorkspaceTabMenuEntries(
entries.push({
kind: "item",
key: "close-after",
label: buildCloseAfterLabel(surface),
label: buildCloseAfterLabel(surface, labels),
icon: "arrow-right-to-line",
disabled: isLastTab,
testID: `${menuTestIDBase}-${buildCloseAfterTestIDSuffix(surface)}`,
@@ -190,7 +228,7 @@ export function buildWorkspaceTabMenuEntries(
entries.push({
kind: "item",
key: "close-others",
label: "Close other tabs",
label: labels.closeOthers,
icon: "copy-x",
disabled: isOnlyTab,
testID: `${menuTestIDBase}-close-others`,
@@ -203,9 +241,9 @@ export function buildWorkspaceTabMenuEntries(
entries.push({
kind: "item",
key: "reload-agent",
label: "Reload agent",
label: labels.reloadAgent,
icon: "rotate-cw",
tooltip: "Reload agent to update skills, MCPs or login status.",
tooltip: labels.reloadAgentTooltip,
testID: `${menuTestIDBase}-reload-agent`,
onSelect: () => {
void onReloadAgent(agentId);
@@ -215,7 +253,7 @@ export function buildWorkspaceTabMenuEntries(
entries.push({
kind: "item",
key: "close",
label: "Close",
label: labels.close,
icon: "x",
testID: `${menuTestIDBase}-close`,
onSelect: () => {
@@ -246,6 +284,7 @@ export function buildWorkspaceDesktopTabActions(
onCloseTabsBefore: input.onCloseTabsToLeft,
onCloseTabsAfter: input.onCloseTabsToRight,
onCloseOtherTabs: input.onCloseOtherTabs,
labels: input.labels,
}),
closeButtonTestId: getCloseButtonTestId(input.tab),
};
@@ -2,6 +2,7 @@ import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Check } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import invariant from "tiny-invariant";
import { SyncedLoader } from "@/components/synced-loader";
import { ensurePanelsRegistered } from "@/panels/register-panels";
@@ -181,6 +182,7 @@ export function WorkspaceTabOptionRow({
onPress,
trailingAccessory,
}: WorkspaceTabOptionRowProps): ReactElement {
const { t } = useTranslation();
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.optionMainPressable,
@@ -200,7 +202,9 @@ export function WorkspaceTabOptionRow({
</View>
<View style={styles.optionContent}>
<Text numberOfLines={1} style={styles.optionLabel}>
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
{presentation.titleState === "loading"
? t("workspace.tabs.loading")
: presentation.label}
</Text>
</View>
</Pressable>