Complete client UI i18n migration
Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user