Complete client UI i18n migration
Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type { PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -151,6 +152,8 @@ function RefPickerTrigger({
|
||||
badgePressableStyle,
|
||||
selectedItem,
|
||||
triggerLabel,
|
||||
accessibilityLabel,
|
||||
tooltipLabel,
|
||||
iconColor,
|
||||
iconSize,
|
||||
}: {
|
||||
@@ -160,6 +163,8 @@ function RefPickerTrigger({
|
||||
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
|
||||
selectedItem: PickerItem | null;
|
||||
triggerLabel: string;
|
||||
accessibilityLabel: string;
|
||||
tooltipLabel: string;
|
||||
iconColor: string;
|
||||
iconSize: number;
|
||||
}) {
|
||||
@@ -173,7 +178,7 @@ function RefPickerTrigger({
|
||||
disabled={disabled}
|
||||
style={badgePressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Starting ref"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
>
|
||||
<RefPickerBadgeContent
|
||||
selectedItem={selectedItem}
|
||||
@@ -184,7 +189,7 @@ function RefPickerTrigger({
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Choose where to start from</Text>
|
||||
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -253,13 +258,17 @@ function ProjectPickerTrigger({
|
||||
}
|
||||
|
||||
function CheckoutHintBadge({
|
||||
prNumber,
|
||||
label,
|
||||
acceptLabel,
|
||||
dismissLabel,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
iconColor,
|
||||
iconSize,
|
||||
}: {
|
||||
prNumber: number;
|
||||
label: string;
|
||||
acceptLabel: string;
|
||||
dismissLabel: string;
|
||||
onAccept: () => void;
|
||||
onDismiss: () => void;
|
||||
iconColor: string;
|
||||
@@ -268,14 +277,14 @@ function CheckoutHintBadge({
|
||||
return (
|
||||
<View style={styles.checkoutHintBadge}>
|
||||
<Text style={styles.badgeText} numberOfLines={1}>
|
||||
Check out PR #{prNumber}?
|
||||
{label}
|
||||
</Text>
|
||||
<Pressable
|
||||
testID="new-workspace-checkout-hint-accept"
|
||||
onPress={onAccept}
|
||||
style={styles.checkoutHintAction}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Check out PR #${prNumber}`}
|
||||
accessibilityLabel={acceptLabel}
|
||||
>
|
||||
<Check size={iconSize} color={iconColor} />
|
||||
</Pressable>
|
||||
@@ -284,7 +293,7 @@ function CheckoutHintBadge({
|
||||
onPress={onDismiss}
|
||||
style={styles.checkoutHintAction}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Dismiss PR #${prNumber} checkout hint`}
|
||||
accessibilityLabel={dismissLabel}
|
||||
>
|
||||
<X size={iconSize} color={iconColor} />
|
||||
</Pressable>
|
||||
@@ -585,10 +594,11 @@ async function createAndMergeWorkspace(input: {
|
||||
workspaces: ReturnType<typeof normalizeWorkspaceDescriptor>[],
|
||||
) => void;
|
||||
serverId: string;
|
||||
createFailedMessage: string;
|
||||
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
|
||||
const payload = await input.client.createPaseoWorktree(input.createInput);
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error ?? "Failed to create worktree");
|
||||
throw new Error(payload.error ?? input.createFailedMessage);
|
||||
}
|
||||
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
|
||||
const workspaceForInitialMerge = input.createInput.firstAgentContext
|
||||
@@ -608,17 +618,21 @@ interface CreateChatAgentInput {
|
||||
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
labels: {
|
||||
composerStateRequired: string;
|
||||
selectModel: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
||||
const { payload, composerState, ensureWorkspace, serverId, draftKey } = input;
|
||||
const { text, attachments, cwd } = payload;
|
||||
if (!composerState) {
|
||||
throw new Error("Composer state is required");
|
||||
throw new Error(input.labels.composerStateRequired);
|
||||
}
|
||||
const provider = composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
throw new Error("Select a model");
|
||||
throw new Error(input.labels.selectModel);
|
||||
}
|
||||
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments);
|
||||
const ensuredWorkspace = await ensureWorkspace({
|
||||
@@ -755,6 +769,7 @@ export function NewWorkspaceScreen({
|
||||
displayName: displayNameProp,
|
||||
}: NewWorkspaceScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const toast = useToast();
|
||||
@@ -816,10 +831,10 @@ export function NewWorkspaceScreen({
|
||||
|
||||
const withConnectedClient = useCallback(() => {
|
||||
if (!client || !isConnected) {
|
||||
throw new Error("Host is not connected");
|
||||
throw new Error(t("newWorkspace.errors.hostDisconnected"));
|
||||
}
|
||||
return client;
|
||||
}, [client, isConnected]);
|
||||
}, [client, isConnected, t]);
|
||||
|
||||
const clientReady = isConnected && Boolean(client);
|
||||
const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
|
||||
@@ -1034,11 +1049,12 @@ export function NewWorkspaceScreen({
|
||||
createInput: buildCreateWorktreeInput(input),
|
||||
mergeWorkspaces,
|
||||
serverId,
|
||||
createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"),
|
||||
});
|
||||
setCreatedWorkspace(normalizedWorkspace);
|
||||
return normalizedWorkspace;
|
||||
},
|
||||
[buildCreateWorktreeInput, createdWorkspace, mergeWorkspaces, serverId, withConnectedClient],
|
||||
[buildCreateWorktreeInput, createdWorkspace, mergeWorkspaces, serverId, t, withConnectedClient],
|
||||
);
|
||||
|
||||
const handleSubmitNewWorkspace = useCallback(
|
||||
@@ -1064,6 +1080,10 @@ export function NewWorkspaceScreen({
|
||||
ensureWorkspace,
|
||||
serverId,
|
||||
draftKey,
|
||||
labels: {
|
||||
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
|
||||
selectModel: t("newWorkspace.errors.selectModel"),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
@@ -1072,7 +1092,7 @@ export function NewWorkspaceScreen({
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
[composerState, draftKey, ensureWorkspace, serverId, toast],
|
||||
[composerState, draftKey, ensureWorkspace, serverId, t, toast],
|
||||
);
|
||||
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
@@ -1105,7 +1125,9 @@ export function NewWorkspaceScreen({
|
||||
: `new-workspace-ref-picker-pr-${item.item.number}`;
|
||||
|
||||
const description =
|
||||
!isBranch && item.item.baseRefName ? `into ${item.item.baseRefName}` : undefined;
|
||||
!isBranch && item.item.baseRefName
|
||||
? t("newWorkspace.refPicker.intoBase", { baseRef: item.item.baseRefName })
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<PickerOptionItem
|
||||
@@ -1122,7 +1144,7 @@ export function NewWorkspaceScreen({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[isPending, itemById, theme.colors.foregroundMuted, theme.iconSize.sm],
|
||||
[isPending, itemById, t, theme.colors.foregroundMuted, theme.iconSize.sm],
|
||||
);
|
||||
|
||||
const renderProjectOption = useCallback(
|
||||
@@ -1175,8 +1197,8 @@ export function NewWorkspaceScreen({
|
||||
|
||||
const pickerEmptyText =
|
||||
branchSuggestionsQuery.isFetching || githubPrSearchQuery.isFetching
|
||||
? "Searching..."
|
||||
: "No matching refs.";
|
||||
? t("newWorkspace.refPicker.searching")
|
||||
: t("newWorkspace.refPicker.noMatchingRefs");
|
||||
|
||||
const composerFooter = useMemo(
|
||||
() => (
|
||||
@@ -1220,6 +1242,8 @@ export function NewWorkspaceScreen({
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
selectedItem={selectedItem}
|
||||
triggerLabel={triggerLabel}
|
||||
accessibilityLabel={t("newWorkspace.refPicker.startingRef")}
|
||||
tooltipLabel={t("newWorkspace.refPicker.chooseStart")}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
@@ -1228,8 +1252,8 @@ export function NewWorkspaceScreen({
|
||||
value={selectedOptionId}
|
||||
onSelect={handleSelectOption}
|
||||
searchable
|
||||
searchPlaceholder="Search branches and PRs"
|
||||
title="Start from"
|
||||
searchPlaceholder={t("newWorkspace.refPicker.searchPlaceholder")}
|
||||
title={t("newWorkspace.refPicker.title")}
|
||||
open={pickerOpen}
|
||||
onOpenChange={handlePickerOpenChange}
|
||||
onSearchQueryChange={setPickerSearchQuery}
|
||||
@@ -1244,7 +1268,15 @@ export function NewWorkspaceScreen({
|
||||
) : null}
|
||||
{checkoutHintPrAttachment ? (
|
||||
<CheckoutHintBadge
|
||||
prNumber={checkoutHintPrAttachment.item.number}
|
||||
label={t("newWorkspace.refPicker.checkoutHint", {
|
||||
number: checkoutHintPrAttachment.item.number,
|
||||
})}
|
||||
acceptLabel={t("newWorkspace.refPicker.checkoutPr", {
|
||||
number: checkoutHintPrAttachment.item.number,
|
||||
})}
|
||||
dismissLabel={t("newWorkspace.refPicker.dismissCheckoutHint", {
|
||||
number: checkoutHintPrAttachment.item.number,
|
||||
})}
|
||||
onAccept={acceptCheckoutHint}
|
||||
onDismiss={dismissCheckoutHint}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
@@ -1281,6 +1313,7 @@ export function NewWorkspaceScreen({
|
||||
selectedSourceDirectory,
|
||||
setPickerSearchQuery,
|
||||
agentControlsWithDisabled,
|
||||
t,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.sm,
|
||||
triggerLabel,
|
||||
@@ -1296,7 +1329,7 @@ export function NewWorkspaceScreen({
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.centered}>
|
||||
<View style={styles.composerTitleContainer}>
|
||||
<Text style={styles.composerTitle}>New workspace</Text>
|
||||
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
|
||||
</View>
|
||||
<Composer
|
||||
agentId={draftKey}
|
||||
@@ -1304,7 +1337,7 @@ export function NewWorkspaceScreen({
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitNewWorkspace}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel="Create"
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.create")}
|
||||
submitIcon="return"
|
||||
isSubmitLoading={pendingAction !== null}
|
||||
submitBehavior="preserve-and-lock"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState, type ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -24,6 +25,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import type { Href } from "expo-router";
|
||||
|
||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
|
||||
const openProjectPicker = useOpenProjectPicker(serverId);
|
||||
@@ -76,31 +78,31 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
<View style={styles.tiles}>
|
||||
<HomeTile
|
||||
icon={FolderOpen}
|
||||
title="Add a project"
|
||||
description="Open a folder on your machine"
|
||||
title={t("openProject.tiles.addProject.title")}
|
||||
description={t("openProject.tiles.addProject.description")}
|
||||
onPress={handleOpenPicker}
|
||||
testID="open-project-submit"
|
||||
accent
|
||||
/>
|
||||
<HomeTile
|
||||
icon={Inbox}
|
||||
title="Import session"
|
||||
description="Bring in recent external CLI sessions"
|
||||
title={t("openProject.tiles.importSession.title")}
|
||||
description={t("openProject.tiles.importSession.description")}
|
||||
onPress={handleOpenImportSession}
|
||||
testID="open-project-import-session"
|
||||
/>
|
||||
<HomeTile
|
||||
icon={Plug}
|
||||
title="Setup providers"
|
||||
description="Configure Claude Code, Codex, and more"
|
||||
title={t("openProject.tiles.setupProviders.title")}
|
||||
description={t("openProject.tiles.setupProviders.description")}
|
||||
onPress={handleOpenProviders}
|
||||
testID="open-project-setup-providers"
|
||||
/>
|
||||
{isLocalDaemon ? (
|
||||
<HomeTile
|
||||
icon={Smartphone}
|
||||
title="Pair device"
|
||||
description="Connect your phone to this daemon"
|
||||
title={t("openProject.tiles.pairDevice.title")}
|
||||
description={t("openProject.tiles.pairDevice.description")}
|
||||
onPress={handleOpenPairDevice}
|
||||
testID="open-project-pair-device"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, TextInput, View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -49,52 +51,40 @@ const SCRIPT_SERVICE_TYPE = "service";
|
||||
const ICON_SIZE = 14;
|
||||
|
||||
interface MetadataPromptField {
|
||||
title: string;
|
||||
placeholder: string;
|
||||
titleKey: string;
|
||||
placeholderKey: string;
|
||||
sectionTestID: string;
|
||||
inputTestID: string;
|
||||
}
|
||||
|
||||
const METADATA_PROMPT_FIELDS: Record<MetadataPromptKey, MetadataPromptField> = {
|
||||
agentTitle: {
|
||||
title: "Agent titles",
|
||||
placeholder: "Keep titles imperative and under 40 characters",
|
||||
titleKey: "settings.project.metadata.agentTitle",
|
||||
placeholderKey: "settings.project.metadata.agentTitlePlaceholder",
|
||||
sectionTestID: "metadata-prompt-agent-title-section",
|
||||
inputTestID: "metadata-prompt-agent-title-input",
|
||||
},
|
||||
branchName: {
|
||||
title: "Branch names",
|
||||
placeholder: "Prefix branches with feat/ or fix/, mb/ for personal branches",
|
||||
titleKey: "settings.project.metadata.branchName",
|
||||
placeholderKey: "settings.project.metadata.branchNamePlaceholder",
|
||||
sectionTestID: "metadata-prompt-branch-name-section",
|
||||
inputTestID: "metadata-prompt-branch-name-input",
|
||||
},
|
||||
commitMessage: {
|
||||
title: "Commit messages",
|
||||
placeholder: "Use Conventional Commits with a scope",
|
||||
titleKey: "settings.project.metadata.commitMessage",
|
||||
placeholderKey: "settings.project.metadata.commitMessagePlaceholder",
|
||||
sectionTestID: "metadata-prompt-commit-message-section",
|
||||
inputTestID: "metadata-prompt-commit-message-input",
|
||||
},
|
||||
pullRequest: {
|
||||
title: "Pull requests",
|
||||
placeholder: "Lead with a one-paragraph summary, include a Test plan section",
|
||||
titleKey: "settings.project.metadata.pullRequest",
|
||||
placeholderKey: "settings.project.metadata.pullRequestPlaceholder",
|
||||
sectionTestID: "metadata-prompt-pull-request-section",
|
||||
inputTestID: "metadata-prompt-pull-request-input",
|
||||
},
|
||||
};
|
||||
|
||||
const WORKTREE_GROUP_INFO =
|
||||
"Commands that run when a worktree is created or torn down for this project";
|
||||
const WORKTREE_DOCS_URL = "https://paseo.sh/docs/worktrees";
|
||||
const WORKTREE_DOCS_TOOLTIP =
|
||||
"See docs for more details and the environment variables available to these commands";
|
||||
const SCRIPTS_GROUP_INFO =
|
||||
"Long-running services and one-off commands you can launch from any agent in this project";
|
||||
const METADATA_GROUP_INFO =
|
||||
"Project-specific instructions injected into the AI prompts Paseo uses to generate metadata — use them to enforce your team's conventions like branch naming, commit style, or PR format";
|
||||
|
||||
const NO_TARGET_MESSAGE = "We don't have an editable copy of this project on any connected host.";
|
||||
|
||||
const HOST_SWITCHER_LABEL = "Switch host";
|
||||
|
||||
type ReadProjectConfigData = Awaited<ReturnType<DaemonClient["readProjectConfig"]>>;
|
||||
|
||||
@@ -158,34 +148,36 @@ function navigateBackToProjects() {
|
||||
}
|
||||
|
||||
function NoEditableTarget() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.noTargetContainer}>
|
||||
<BackToProjectsButton />
|
||||
<Text style={styles.noTargetText}>{NO_TARGET_MESSAGE}</Text>
|
||||
<Text style={styles.noTargetText}>{t("settings.project.noEditableTarget")}</Text>
|
||||
<Button
|
||||
testID="project-settings-back-button"
|
||||
onPress={navigateBackToProjects}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
>
|
||||
Back to projects
|
||||
{t("settings.project.backToProjects")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function BackToProjectsButton() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Button
|
||||
testID="project-settings-back-link"
|
||||
accessibilityLabel="Back to projects"
|
||||
accessibilityLabel={t("settings.project.backToProjects")}
|
||||
onPress={navigateBackToProjects}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={ArrowLeft}
|
||||
style={styles.backButton}
|
||||
>
|
||||
Back to projects
|
||||
{t("settings.project.backToProjects")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -370,12 +362,18 @@ interface ReadFailureCalloutProps {
|
||||
}
|
||||
|
||||
function ReadFailureCallout({ kind, error, onReload, hasMultipleHosts }: ReadFailureCalloutProps) {
|
||||
const { testID, title, description } = resolveReadFailureCopy({ kind, error, hasMultipleHosts });
|
||||
const { t } = useTranslation();
|
||||
const { testID, title, description } = resolveReadFailureCopy({
|
||||
kind,
|
||||
error,
|
||||
hasMultipleHosts,
|
||||
t,
|
||||
});
|
||||
return (
|
||||
<View style={styles.errorBlock}>
|
||||
<Alert testID={testID} variant="error" title={title} description={description}>
|
||||
<Button testID={`${testID}-action-0`} onPress={onReload} variant="outline" size="sm">
|
||||
Reload
|
||||
{t("settings.project.actions.reload")}
|
||||
</Button>
|
||||
</Alert>
|
||||
</View>
|
||||
@@ -386,35 +384,36 @@ function resolveReadFailureCopy(input: {
|
||||
kind: ReadFailureCalloutProps["kind"];
|
||||
error: unknown;
|
||||
hasMultipleHosts: boolean;
|
||||
t: TFunction;
|
||||
}): { testID: string; title: string; description: string } {
|
||||
if (input.kind === "invalid_project_config") {
|
||||
return {
|
||||
testID: "invalid-callout",
|
||||
title: "paseo.json couldn't be parsed",
|
||||
description: "Fix the file on disk, then reload.",
|
||||
title: input.t("settings.project.readFailures.invalidTitle"),
|
||||
description: input.t("settings.project.readFailures.invalidDescription"),
|
||||
};
|
||||
}
|
||||
if (input.kind === "project_not_found") {
|
||||
return {
|
||||
testID: "project-not-found-callout",
|
||||
title: "This host doesn't have this project",
|
||||
title: input.t("settings.project.readFailures.missingTitle"),
|
||||
description: input.hasMultipleHosts
|
||||
? "Switch to another host above, or reload."
|
||||
: "The selected host has no record of this project.",
|
||||
? input.t("settings.project.readFailures.missingWithHosts")
|
||||
: input.t("settings.project.readFailures.missingSingleHost"),
|
||||
};
|
||||
}
|
||||
if (input.kind === "transport") {
|
||||
const detail = errorToDetail(input.error);
|
||||
return {
|
||||
testID: "read-transport-callout",
|
||||
title: "Couldn't load paseo.json",
|
||||
description: detail ?? "The host didn't respond.",
|
||||
title: input.t("settings.project.readFailures.transportTitle"),
|
||||
description: detail ?? input.t("settings.project.readFailures.transportFallback"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
testID: "read-failed-callout",
|
||||
title: "Couldn't load paseo.json",
|
||||
description: "Reload to try again.",
|
||||
title: input.t("settings.project.readFailures.failedTitle"),
|
||||
description: input.t("settings.project.readFailures.failedDescription"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -441,6 +440,7 @@ function ProjectConfigForm({
|
||||
client,
|
||||
onReload,
|
||||
}: ProjectConfigFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
@@ -470,7 +470,7 @@ function ProjectConfigForm({
|
||||
});
|
||||
setWriteError(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
toast.show("Project saved", { variant: "success" });
|
||||
toast.show(t("settings.project.actions.saved"), { variant: "success" });
|
||||
} else {
|
||||
setWriteError(result.error);
|
||||
}
|
||||
@@ -513,10 +513,12 @@ function ProjectConfigForm({
|
||||
const handleRemoveScript = useCallback(
|
||||
async (script: ProjectScriptDraft) => {
|
||||
const ok = await confirmDialog({
|
||||
title: "Remove script?",
|
||||
message: `Remove ${script.name || "this script"}?`,
|
||||
confirmLabel: "Remove",
|
||||
cancelLabel: "Cancel",
|
||||
title: t("settings.project.scripts.removeTitle"),
|
||||
message: t("settings.project.scripts.removeMessage", {
|
||||
name: script.name || t("settings.project.scripts.removeFallbackName"),
|
||||
}),
|
||||
confirmLabel: t("settings.project.scripts.actions.remove"),
|
||||
cancelLabel: t("settings.project.actions.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
@@ -525,7 +527,7 @@ function ProjectConfigForm({
|
||||
scripts: d.scripts.filter((entry) => entry.id !== script.id),
|
||||
}));
|
||||
},
|
||||
[updateDraft],
|
||||
[t, updateDraft],
|
||||
);
|
||||
|
||||
const handleEditScript = useCallback((script: ProjectScriptDraft) => {
|
||||
@@ -587,8 +589,8 @@ function ProjectConfigForm({
|
||||
const editingScript = draft.scripts.find((entry) => entry.id === editingScriptId);
|
||||
|
||||
const hasInvalidScripts = useMemo(
|
||||
() => draft.scripts.some((script) => validateScript(script).hasErrors),
|
||||
[draft.scripts],
|
||||
() => draft.scripts.some((script) => validateScript(script, t).hasErrors),
|
||||
[draft.scripts, t],
|
||||
);
|
||||
|
||||
const scriptsTrailing = useMemo(
|
||||
@@ -598,36 +600,36 @@ function ProjectConfigForm({
|
||||
hitSlop={8}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Add script"
|
||||
accessibilityLabel={t("settings.project.scripts.actions.add")}
|
||||
testID="scripts-add-button"
|
||||
>
|
||||
<Plus size={ICON_SIZE} color={styles.iconColor.color} />
|
||||
</Pressable>
|
||||
),
|
||||
[handleAddScript],
|
||||
[handleAddScript, t],
|
||||
);
|
||||
|
||||
const setupDocsLink = useMemo(
|
||||
() => (
|
||||
<ExternalLink
|
||||
href={WORKTREE_DOCS_URL}
|
||||
label="Docs"
|
||||
tooltip={WORKTREE_DOCS_TOOLTIP}
|
||||
label={t("settings.project.worktree.docs")}
|
||||
tooltip={t("settings.project.worktree.docsTooltip")}
|
||||
testID="worktree-setup-docs-link"
|
||||
/>
|
||||
),
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
const teardownDocsLink = useMemo(
|
||||
() => (
|
||||
<ExternalLink
|
||||
href={WORKTREE_DOCS_URL}
|
||||
label="Docs"
|
||||
tooltip={WORKTREE_DOCS_TOOLTIP}
|
||||
label={t("settings.project.worktree.docs")}
|
||||
tooltip={t("settings.project.worktree.docsTooltip")}
|
||||
testID="worktree-teardown-docs-link"
|
||||
/>
|
||||
),
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const isStale = writeError?.code === "stale_project_config";
|
||||
@@ -637,14 +639,18 @@ function ProjectConfigForm({
|
||||
return (
|
||||
<View>
|
||||
<SettingsGroup
|
||||
title="Worktree lifecycle hooks"
|
||||
info={WORKTREE_GROUP_INFO}
|
||||
title={t("settings.project.worktree.title")}
|
||||
info={t("settings.project.worktree.info")}
|
||||
testID="worktree-group"
|
||||
>
|
||||
<SettingsSection title="Setup" testID="worktree-setup-section" trailing={setupDocsLink}>
|
||||
<SettingsSection
|
||||
title={t("settings.project.worktree.setup")}
|
||||
testID="worktree-setup-section"
|
||||
trailing={setupDocsLink}
|
||||
>
|
||||
<SettingsTextAreaCard
|
||||
testID="worktree-setup-input"
|
||||
accessibilityLabel="Worktree setup commands"
|
||||
accessibilityLabel={t("settings.project.worktree.setupAccessibility")}
|
||||
value={draft.setupText}
|
||||
onChangeText={handleSetupChange}
|
||||
placeholder="npm install"
|
||||
@@ -652,14 +658,14 @@ function ProjectConfigForm({
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Teardown"
|
||||
title={t("settings.project.worktree.teardown")}
|
||||
testID="worktree-teardown-section"
|
||||
trailing={teardownDocsLink}
|
||||
flush
|
||||
>
|
||||
<SettingsTextAreaCard
|
||||
testID="worktree-teardown-input"
|
||||
accessibilityLabel="Worktree teardown commands"
|
||||
accessibilityLabel={t("settings.project.worktree.teardownAccessibility")}
|
||||
value={draft.teardownText}
|
||||
onChangeText={handleTeardownChange}
|
||||
placeholder="docker compose down"
|
||||
@@ -668,15 +674,15 @@ function ProjectConfigForm({
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup
|
||||
title="Scripts"
|
||||
info={SCRIPTS_GROUP_INFO}
|
||||
title={t("settings.project.scripts.title")}
|
||||
info={t("settings.project.scripts.info")}
|
||||
trailing={scriptsTrailing}
|
||||
testID="scripts-group"
|
||||
>
|
||||
<View style={settingsStyles.card} testID="scripts-list">
|
||||
{draft.scripts.length === 0 ? (
|
||||
<View style={settingsStyles.row}>
|
||||
<Text style={styles.emptyScripts}>No scripts yet.</Text>
|
||||
<Text style={styles.emptyScripts}>{t("settings.project.scripts.empty")}</Text>
|
||||
</View>
|
||||
) : (
|
||||
draft.scripts.map((script, index) => (
|
||||
@@ -692,7 +698,11 @@ function ProjectConfigForm({
|
||||
</View>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Metadata generation" info={METADATA_GROUP_INFO} testID="metadata-group">
|
||||
<SettingsGroup
|
||||
title={t("settings.project.metadata.title")}
|
||||
info={t("settings.project.metadata.info")}
|
||||
testID="metadata-group"
|
||||
>
|
||||
{METADATA_PROMPT_KEYS.map((key, index) => (
|
||||
<MetadataPromptSection
|
||||
key={key}
|
||||
@@ -709,8 +719,8 @@ function ProjectConfigForm({
|
||||
<Alert
|
||||
testID="stale-callout"
|
||||
variant="error"
|
||||
title="Config changed on disk"
|
||||
description="Reload to fetch the latest paseo.json before saving."
|
||||
title={t("settings.project.writeFailures.staleTitle")}
|
||||
description={t("settings.project.writeFailures.staleDescription")}
|
||||
>
|
||||
<Button
|
||||
testID="stale-callout-action-0"
|
||||
@@ -718,7 +728,7 @@ function ProjectConfigForm({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Reload
|
||||
{t("settings.project.actions.reload")}
|
||||
</Button>
|
||||
</Alert>
|
||||
</View>
|
||||
@@ -729,8 +739,8 @@ function ProjectConfigForm({
|
||||
<Alert
|
||||
testID="write-failed-callout"
|
||||
variant="error"
|
||||
title="Couldn't save paseo.json"
|
||||
description="Try again, or reload the latest version from disk."
|
||||
title={t("settings.project.writeFailures.failedTitle")}
|
||||
description={t("settings.project.writeFailures.failedDescription")}
|
||||
>
|
||||
<Button
|
||||
testID="write-failed-callout-action-0"
|
||||
@@ -738,7 +748,7 @@ function ProjectConfigForm({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Try again
|
||||
{t("settings.project.actions.tryAgain")}
|
||||
</Button>
|
||||
<Button
|
||||
testID="write-failed-callout-action-1"
|
||||
@@ -746,7 +756,7 @@ function ProjectConfigForm({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Reload
|
||||
{t("settings.project.actions.reload")}
|
||||
</Button>
|
||||
</Alert>
|
||||
</View>
|
||||
@@ -755,14 +765,16 @@ function ProjectConfigForm({
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
testID="save-button"
|
||||
accessibilityLabel="Save project config"
|
||||
accessibilityLabel={t("settings.project.actions.save")}
|
||||
variant="default"
|
||||
size="md"
|
||||
disabled={saveDisabled}
|
||||
loading={saveMutation.isPending}
|
||||
onPress={handleSave}
|
||||
>
|
||||
{saveMutation.isPending ? "Saving…" : "Save"}
|
||||
{saveMutation.isPending
|
||||
? t("settings.project.actions.saving")
|
||||
: t("settings.project.actions.save")}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -788,6 +800,7 @@ interface ProjectNameEditorProps {
|
||||
}
|
||||
|
||||
function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -798,10 +811,11 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
setIsEditing(false);
|
||||
toast.show("Project renamed", { variant: "success" });
|
||||
toast.show(t("settings.project.rename.renamedToast"), { variant: "success" });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error instanceof Error ? error.message : "Couldn't rename project";
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("settings.project.rename.errorFallback");
|
||||
toast.show(message, { variant: "error" });
|
||||
},
|
||||
});
|
||||
@@ -838,7 +852,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
</Text>
|
||||
<Pressable
|
||||
testID="project-name-edit-button"
|
||||
accessibilityLabel="Rename project"
|
||||
accessibilityLabel={t("settings.project.rename.renameLabel")}
|
||||
onPress={handleStartEdit}
|
||||
hitSlop={8}
|
||||
style={styles.nameEditorIconButton}
|
||||
@@ -848,13 +862,13 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
{project.projectCustomName ? (
|
||||
<Pressable
|
||||
testID="project-name-reset-button"
|
||||
accessibilityLabel="Reset project name to default"
|
||||
accessibilityLabel={t("settings.project.rename.resetLabel")}
|
||||
onPress={handleReset}
|
||||
disabled={renameMutation.isPending}
|
||||
hitSlop={8}
|
||||
style={styles.nameEditorResetButton}
|
||||
>
|
||||
<Text style={styles.nameEditorResetText}>Reset</Text>
|
||||
<Text style={styles.nameEditorResetText}>{t("settings.project.rename.reset")}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -865,7 +879,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
<View style={styles.nameEditorRow}>
|
||||
<TextInput
|
||||
testID="project-name-input"
|
||||
accessibilityLabel="Project name"
|
||||
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
placeholder={project.projectName}
|
||||
@@ -878,7 +892,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
/>
|
||||
<Pressable
|
||||
testID="project-name-save-button"
|
||||
accessibilityLabel="Save project name"
|
||||
accessibilityLabel={t("settings.project.rename.saveLabel")}
|
||||
onPress={handleSave}
|
||||
disabled={renameMutation.isPending}
|
||||
hitSlop={8}
|
||||
@@ -888,7 +902,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="project-name-cancel-button"
|
||||
accessibilityLabel="Cancel renaming"
|
||||
accessibilityLabel={t("settings.project.rename.cancelLabel")}
|
||||
onPress={handleCancel}
|
||||
disabled={renameMutation.isPending}
|
||||
hitSlop={8}
|
||||
@@ -961,10 +975,11 @@ interface HostPickerProps {
|
||||
}
|
||||
|
||||
function HostPicker({ hosts, selectedHost, onSelectHost }: HostPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
accessibilityLabel={HOST_SWITCHER_LABEL}
|
||||
accessibilityLabel={t("settings.project.switchHost")}
|
||||
testID="host-picker"
|
||||
style={styles.hostIndicator}
|
||||
>
|
||||
@@ -1018,19 +1033,21 @@ interface MetadataPromptSectionProps {
|
||||
}
|
||||
|
||||
function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPromptSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const meta = METADATA_PROMPT_FIELDS[promptKey];
|
||||
const title = t(meta.titleKey);
|
||||
const handleChange = useCallback(
|
||||
(text: string) => onChange(promptKey, text),
|
||||
[onChange, promptKey],
|
||||
);
|
||||
return (
|
||||
<SettingsSection title={meta.title} testID={meta.sectionTestID} flush={flush}>
|
||||
<SettingsSection title={title} testID={meta.sectionTestID} flush={flush}>
|
||||
<SettingsTextAreaCard
|
||||
testID={meta.inputTestID}
|
||||
accessibilityLabel={meta.title}
|
||||
accessibilityLabel={title}
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
placeholder={meta.placeholder}
|
||||
placeholder={t(meta.placeholderKey)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
@@ -1044,6 +1061,7 @@ interface ScriptRowProps {
|
||||
}
|
||||
|
||||
function ScriptRow({ script, isFirst, onEdit, onRemove }: ScriptRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleEdit = useCallback(() => onEdit(script), [onEdit, script]);
|
||||
const handleRemove = useCallback(() => onRemove(script), [onRemove, script]);
|
||||
const rowStyle = isFirst ? styles.scriptRow : styles.scriptRowWithBorder;
|
||||
@@ -1052,15 +1070,15 @@ function ScriptRow({ script, isFirst, onEdit, onRemove }: ScriptRowProps) {
|
||||
<View style={rowStyle} testID={`script-row-${script.id}`}>
|
||||
<Pressable style={styles.scriptRowMain} onPress={handleEdit}>
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{script.name || "Untitled script"}
|
||||
{script.name || t("settings.project.scripts.untitled")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||
{scriptHint(script)}
|
||||
{scriptHint(script, t)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
accessibilityLabel="Open script menu"
|
||||
accessibilityLabel={t("settings.project.scripts.menuAccessibility")}
|
||||
testID={`script-row-menu-${script.id}`}
|
||||
style={styles.scriptKebab}
|
||||
>
|
||||
@@ -1068,14 +1086,14 @@ function ScriptRow({ script, isFirst, onEdit, onRemove }: ScriptRowProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" minWidth={160}>
|
||||
<DropdownMenuItem testID={`script-action-${script.id}-edit`} onSelect={handleEdit}>
|
||||
Edit
|
||||
{t("settings.project.scripts.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
testID={`script-action-${script.id}-remove`}
|
||||
destructive
|
||||
onSelect={handleRemove}
|
||||
>
|
||||
Remove
|
||||
{t("settings.project.scripts.actions.remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -1083,10 +1101,10 @@ function ScriptRow({ script, isFirst, onEdit, onRemove }: ScriptRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function scriptHint(script: ProjectScriptDraft): string {
|
||||
function scriptHint(script: ProjectScriptDraft, t: TFunction): string {
|
||||
const pieces: string[] = [];
|
||||
if (script.type) pieces.push(script.type);
|
||||
if (script.portText) pieces.push(`port ${script.portText}`);
|
||||
if (script.portText) pieces.push(t("settings.project.scripts.port", { port: script.portText }));
|
||||
if (script.commandText) pieces.push(script.commandText.split("\n")[0] ?? "");
|
||||
return pieces.join(" · ");
|
||||
}
|
||||
@@ -1097,9 +1115,11 @@ interface ScriptValidation {
|
||||
commandError: string | null;
|
||||
}
|
||||
|
||||
function validateScript(script: ProjectScriptDraft): ScriptValidation {
|
||||
const nameError = script.name.trim().length === 0 ? "Name is required" : null;
|
||||
const commandError = script.commandText.trim().length === 0 ? "Command is required" : null;
|
||||
function validateScript(script: ProjectScriptDraft, t: TFunction): ScriptValidation {
|
||||
const nameError =
|
||||
script.name.trim().length === 0 ? t("settings.project.scripts.nameRequired") : null;
|
||||
const commandError =
|
||||
script.commandText.trim().length === 0 ? t("settings.project.scripts.commandRequired") : null;
|
||||
return {
|
||||
hasErrors: Boolean(nameError || commandError),
|
||||
nameError,
|
||||
@@ -1123,6 +1143,7 @@ const ALL_TOUCHED: ScriptFieldsTouched = { name: true, command: true };
|
||||
const NONE_TOUCHED: ScriptFieldsTouched = { name: false, command: false };
|
||||
|
||||
function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [touched, setTouched] = useState<ScriptFieldsTouched>(NONE_TOUCHED);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1149,7 +1170,7 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal
|
||||
const handleNameBlur = useCallback(() => markTouched("name"), [markTouched]);
|
||||
const handleCommandBlur = useCallback(() => markTouched("command"), [markTouched]);
|
||||
|
||||
const validation = validateScript(script);
|
||||
const validation = validateScript(script, t);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
if (validation.hasErrors) {
|
||||
@@ -1163,8 +1184,12 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal
|
||||
const showCommandError = touched.command && validation.commandError;
|
||||
const isService = script.type === SCRIPT_SERVICE_TYPE;
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({ title: script.name ? `Edit ${script.name}` : "New script" }),
|
||||
[script.name],
|
||||
() => ({
|
||||
title: script.name
|
||||
? t("settings.project.scripts.editScript", { name: script.name })
|
||||
: t("settings.project.scripts.newScript"),
|
||||
}),
|
||||
[script.name, t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -1176,10 +1201,10 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal
|
||||
desktopMaxWidth={560}
|
||||
>
|
||||
<View style={styles.modalSection}>
|
||||
<Text style={styles.modalLabel}>Name</Text>
|
||||
<Text style={styles.modalLabel}>{t("settings.project.scripts.name")}</Text>
|
||||
<TextInput
|
||||
testID="script-edit-name"
|
||||
accessibilityLabel="Script name"
|
||||
accessibilityLabel={t("settings.project.scripts.nameAccessibility")}
|
||||
value={script.name}
|
||||
onChangeText={handleNameChange}
|
||||
onBlur={handleNameBlur}
|
||||
@@ -1194,10 +1219,10 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.modalSection}>
|
||||
<Text style={styles.modalLabel}>Command</Text>
|
||||
<Text style={styles.modalLabel}>{t("settings.project.scripts.command")}</Text>
|
||||
<TextInput
|
||||
testID="script-edit-command"
|
||||
accessibilityLabel="Script command"
|
||||
accessibilityLabel={t("settings.project.scripts.commandAccessibility")}
|
||||
multiline
|
||||
value={script.commandText}
|
||||
onChangeText={handleCommandChange}
|
||||
@@ -1215,25 +1240,25 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal
|
||||
<View style={styles.modalSection}>
|
||||
<View style={styles.serviceToggleRow}>
|
||||
<View style={styles.serviceToggleText}>
|
||||
<Text style={styles.serviceToggleLabel}>Run as a service</Text>
|
||||
<Text style={styles.modalHint}>
|
||||
Paseo supervises the process and assigns a port via $PASEO_PORT
|
||||
<Text style={styles.serviceToggleLabel}>
|
||||
{t("settings.project.scripts.runAsService")}
|
||||
</Text>
|
||||
<Text style={styles.modalHint}>{t("settings.project.scripts.serviceHint")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isService}
|
||||
onValueChange={handleServiceToggle}
|
||||
accessibilityLabel="Run as a service"
|
||||
accessibilityLabel={t("settings.project.scripts.runAsService")}
|
||||
testID="script-edit-service-toggle"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.modalFooter}>
|
||||
<Button onPress={onCancel} variant="ghost" size="md" testID="script-edit-cancel">
|
||||
Cancel
|
||||
{t("settings.project.actions.cancel")}
|
||||
</Button>
|
||||
<Button onPress={handleSavePress} variant="default" size="md" testID="script-edit-save">
|
||||
Save
|
||||
{t("settings.project.actions.save")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
@@ -125,6 +125,19 @@ vi.mock("expo-router", () => ({
|
||||
router: { navigate },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, string>) => {
|
||||
if (key === "sidebar.project.empty.title") return "No projects yet";
|
||||
if (key === "settings.projectList.hostLoadFailed") {
|
||||
return `Couldn't load projects from host ${values?.hostName}: ${values?.message}`;
|
||||
}
|
||||
if (key === "settings.projectList.editProject") return `Edit ${values?.projectName}`;
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/loading-spinner", () => ({
|
||||
LoadingSpinner: ({ size }: { size?: string | number }) =>
|
||||
React.createElement("span", {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Pressable, Text, View, type PressableStateCallbackType } from "react-na
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
|
||||
@@ -16,6 +17,7 @@ interface ProjectsScreenProps {
|
||||
}
|
||||
|
||||
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||
const { t } = useTranslation();
|
||||
const { projects, hostErrors, isLoading } = useProjects();
|
||||
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
|
||||
const iconTargets = useMemo(
|
||||
@@ -49,7 +51,7 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<View style={styles.centered} testID="projects-list">
|
||||
<Text style={styles.emptyText}>No projects yet</Text>
|
||||
<Text style={styles.emptyText}>{t("sidebar.project.empty.title")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -73,11 +75,15 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||
}
|
||||
|
||||
function HostErrorsBanner({ errors }: { errors: ProjectHostError[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.errorsBanner} testID="projects-host-errors">
|
||||
{errors.map((error) => (
|
||||
<Text key={error.serverId} style={styles.errorsBannerText}>
|
||||
{`Couldn't load projects from host ${error.serverName}: ${error.message}`}
|
||||
{t("settings.projectList.hostLoadFailed", {
|
||||
hostName: error.serverName,
|
||||
message: error.message,
|
||||
})}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
@@ -92,6 +98,7 @@ interface ProjectRowProps {
|
||||
}
|
||||
|
||||
function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { projectKey, projectName } = project;
|
||||
|
||||
@@ -116,7 +123,7 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
|
||||
style={rowStyle}
|
||||
onPress={handleNavigate}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Edit ${projectName}`}
|
||||
accessibilityLabel={t("settings.projectList.editProject", { projectName })}
|
||||
testID={`project-row-${projectKey}`}
|
||||
data-selected={isSelected ? "true" : "false"}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useIsFocused } from "@react-navigation/native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronLeft } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
@@ -23,6 +24,7 @@ export function SessionsScreen({ serverId }: { serverId: string }) {
|
||||
|
||||
function SessionsScreenContent({ serverId }: { serverId: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const { agents, hasMore, isInitialLoad, isLoadingMore, isRevalidating, loadMore, refreshAll } =
|
||||
useAgentHistory({
|
||||
serverId,
|
||||
@@ -56,16 +58,16 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
|
||||
hasMore ? (
|
||||
<View style={styles.footer}>
|
||||
<Button variant="ghost" onPress={loadMore} disabled={isLoadingMore}>
|
||||
{isLoadingMore ? "Loading..." : "Load more"}
|
||||
{isLoadingMore ? t("common.loading") : t("sessions.actions.loadMore")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null,
|
||||
[hasMore, loadMore, isLoadingMore],
|
||||
[hasMore, loadMore, isLoadingMore, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MenuHeader title="Sessions" />
|
||||
<MenuHeader title={t("sessions.title")} />
|
||||
{isInitialLoad ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
|
||||
@@ -73,9 +75,9 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
|
||||
) : null}
|
||||
{!isInitialLoad && sortedAgents.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No sessions yet</Text>
|
||||
<Text style={styles.emptyText}>{t("sessions.empty")}</Text>
|
||||
<Button variant="ghost" leftIcon={ChevronLeft} onPress={handleBack}>
|
||||
Back
|
||||
{t("common.actions.back")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { useRouter } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Buffer } from "buffer";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -92,6 +94,7 @@ import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { LANGUAGE_OPTIONS, type AppLanguage } from "@/i18n/locales";
|
||||
import {
|
||||
HostConnectionsPage,
|
||||
HostAgentsPage,
|
||||
@@ -127,34 +130,44 @@ export type SettingsView =
|
||||
|
||||
interface SidebarSectionItem {
|
||||
id: SettingsSectionSlug;
|
||||
label: string;
|
||||
labelKey: string;
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
desktopOnly?: boolean;
|
||||
}
|
||||
|
||||
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
||||
{ id: "general", label: "General", icon: Settings },
|
||||
{ id: "daemon", label: "Daemon", icon: Server, desktopOnly: true },
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard, desktopOnly: true },
|
||||
{ id: "integrations", label: "Integrations", icon: Puzzle, desktopOnly: true },
|
||||
{ id: "permissions", label: "Permissions", icon: Shield, desktopOnly: true },
|
||||
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
{ id: "general", labelKey: "settings.sections.general", icon: Settings },
|
||||
{ id: "daemon", labelKey: "settings.sections.daemon", icon: Server, desktopOnly: true },
|
||||
{ id: "appearance", labelKey: "settings.sections.appearance", icon: Palette },
|
||||
{ id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true },
|
||||
{
|
||||
id: "integrations",
|
||||
labelKey: "settings.sections.integrations",
|
||||
icon: Puzzle,
|
||||
desktopOnly: true,
|
||||
},
|
||||
{
|
||||
id: "permissions",
|
||||
labelKey: "settings.sections.permissions",
|
||||
icon: Shield,
|
||||
desktopOnly: true,
|
||||
},
|
||||
{ id: "diagnostics", labelKey: "settings.sections.diagnostics", icon: Stethoscope },
|
||||
{ id: "about", labelKey: "settings.sections.about", icon: Info },
|
||||
];
|
||||
|
||||
interface HostSectionItem {
|
||||
id: HostSectionSlug;
|
||||
label: string;
|
||||
labelKey: string;
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
}
|
||||
|
||||
const HOST_SECTION_ITEMS: HostSectionItem[] = [
|
||||
{ id: "connections", label: "Connections", icon: Network },
|
||||
{ id: "agents", label: "Agents", icon: Bot },
|
||||
{ id: "workspaces", label: "Workspaces", icon: FolderGit2 },
|
||||
{ id: "providers", label: "Providers", icon: Boxes },
|
||||
{ id: "host", label: "Host", icon: Server },
|
||||
{ id: "connections", labelKey: "settings.hostSections.connections", icon: Network },
|
||||
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
|
||||
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
|
||||
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
|
||||
{ id: "host", labelKey: "settings.hostSections.host", icon: Server },
|
||||
];
|
||||
|
||||
function renderHostSettingsContent(
|
||||
@@ -197,21 +210,21 @@ function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { ho
|
||||
|
||||
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||
|
||||
const SEND_BEHAVIOR_OPTIONS = [
|
||||
{ value: "interrupt" as const, label: "Interrupt" },
|
||||
{ value: "queue" as const, label: "Queue" },
|
||||
];
|
||||
function getSendBehaviorOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "interrupt" as const, label: t("settings.general.defaultSend.options.interrupt") },
|
||||
{ value: "queue" as const, label: t("settings.general.defaultSend.options.queue") },
|
||||
];
|
||||
}
|
||||
|
||||
const RELEASE_CHANNEL_OPTIONS = [
|
||||
{ value: "stable" as const, label: "Stable" },
|
||||
{ value: "beta" as const, label: "Beta" },
|
||||
];
|
||||
|
||||
const SERVICE_URL_BEHAVIOR_LABELS: Record<ServiceUrlBehavior, string> = {
|
||||
ask: "Ask",
|
||||
"in-app": "In Paseo",
|
||||
external: "External browser",
|
||||
};
|
||||
function getServiceUrlBehaviorLabel(t: TFunction, value: ServiceUrlBehavior): string {
|
||||
const labels: Record<ServiceUrlBehavior, string> = {
|
||||
ask: t("settings.general.serviceUrls.options.ask"),
|
||||
"in-app": t("settings.general.serviceUrls.options.inApp"),
|
||||
external: t("settings.general.serviceUrls.options.external"),
|
||||
};
|
||||
return labels[value];
|
||||
}
|
||||
|
||||
const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "external"];
|
||||
|
||||
@@ -224,17 +237,20 @@ interface GeneralSectionProps {
|
||||
isDesktopApp: boolean;
|
||||
handleSendBehaviorChange: (behavior: SendBehavior) => void;
|
||||
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
|
||||
handleLanguageChange: (language: AppLanguage) => void;
|
||||
handleTerminalScrollbackLinesChange: (lines: number) => void;
|
||||
}
|
||||
|
||||
interface ServiceUrlBehaviorMenuItemProps {
|
||||
value: ServiceUrlBehavior;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onChange: (value: ServiceUrlBehavior) => void;
|
||||
}
|
||||
|
||||
function ServiceUrlBehaviorMenuItem({
|
||||
value,
|
||||
label,
|
||||
selected,
|
||||
onChange,
|
||||
}: ServiceUrlBehaviorMenuItemProps) {
|
||||
@@ -243,7 +259,28 @@ function ServiceUrlBehaviorMenuItem({
|
||||
}, [onChange, value]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
||||
{SERVICE_URL_BEHAVIOR_LABELS[value]}
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface LanguageMenuItemProps {
|
||||
value: AppLanguage;
|
||||
selected: boolean;
|
||||
onChange: (value: AppLanguage) => void;
|
||||
}
|
||||
|
||||
function LanguageMenuItem({ value, selected, onChange }: LanguageMenuItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleSelect = useCallback(() => {
|
||||
onChange(value);
|
||||
}, [onChange, value]);
|
||||
const option = LANGUAGE_OPTIONS.find((entry) => entry.value === value);
|
||||
const label = option ? t(option.labelKey) : value;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -253,10 +290,19 @@ function GeneralSection({
|
||||
isDesktopApp,
|
||||
handleSendBehaviorChange,
|
||||
handleServiceUrlBehaviorChange,
|
||||
handleLanguageChange,
|
||||
handleTerminalScrollbackLinesChange,
|
||||
}: GeneralSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const iconColor = theme.colors.foregroundMuted;
|
||||
const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]);
|
||||
const selectedLanguageOption = LANGUAGE_OPTIONS.find(
|
||||
(option) => option.value === settings.language,
|
||||
);
|
||||
const selectedLanguageLabel = selectedLanguageOption
|
||||
? t(selectedLanguageOption.labelKey)
|
||||
: settings.language;
|
||||
const [terminalScrollbackValue, setTerminalScrollbackValue] = useState(
|
||||
String(settings.terminalScrollbackLines),
|
||||
);
|
||||
@@ -283,32 +329,60 @@ function GeneralSection({
|
||||
}, [settings.terminalScrollbackLines]);
|
||||
|
||||
return (
|
||||
<SettingsSection title="General">
|
||||
<SettingsSection title={t("settings.general.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Default send</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.general.defaultSend.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
What happens when you press Enter while the agent is running
|
||||
{t("settings.general.defaultSend.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={settings.sendBehavior}
|
||||
onValueChange={handleSendBehaviorChange}
|
||||
options={SEND_BEHAVIOR_OPTIONS}
|
||||
options={sendBehaviorOptions}
|
||||
/>
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.general.language.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.general.language.description")}</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={selectedLanguageLabel}
|
||||
style={themeTriggerStyle}
|
||||
>
|
||||
<Text style={styles.themeTriggerText}>{selectedLanguageLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={220}>
|
||||
{LANGUAGE_OPTIONS.map((option) => (
|
||||
<LanguageMenuItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
selected={settings.language === option.value}
|
||||
onChange={handleLanguageChange}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
{isDesktopApp ? (
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Service URLs</Text>
|
||||
<Text style={settingsStyles.rowHint}>Where to open URLs from running scripts</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.general.serviceUrls.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.general.serviceUrls.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger style={themeTriggerStyle}>
|
||||
<Text style={styles.themeTriggerText}>
|
||||
{SERVICE_URL_BEHAVIOR_LABELS[settings.serviceUrlBehavior]}
|
||||
{getServiceUrlBehaviorLabel(t, settings.serviceUrlBehavior)}
|
||||
</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -317,6 +391,7 @@ function GeneralSection({
|
||||
<ServiceUrlBehaviorMenuItem
|
||||
key={value}
|
||||
value={value}
|
||||
label={getServiceUrlBehaviorLabel(t, value)}
|
||||
selected={settings.serviceUrlBehavior === value}
|
||||
onChange={handleServiceUrlBehaviorChange}
|
||||
/>
|
||||
@@ -327,8 +402,12 @@ function GeneralSection({
|
||||
) : null}
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Terminal scrollback</Text>
|
||||
<Text style={settingsStyles.rowHint}>Lines kept in the built-in terminal buffer</Text>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.general.terminalScrollback.label")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.general.terminalScrollback.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
value={terminalScrollbackValue}
|
||||
@@ -339,7 +418,7 @@ function GeneralSection({
|
||||
inputMode="numeric"
|
||||
selectTextOnFocus
|
||||
style={styles.terminalScrollbackInput}
|
||||
accessibilityLabel="Terminal scrollback lines"
|
||||
accessibilityLabel={t("settings.general.terminalScrollback.accessibilityLabel")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -360,15 +439,16 @@ function DiagnosticsSection({
|
||||
playbackTestResult,
|
||||
handlePlaybackTest,
|
||||
}: DiagnosticsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const handlePlayPress = useCallback(() => {
|
||||
void handlePlaybackTest();
|
||||
}, [handlePlaybackTest]);
|
||||
return (
|
||||
<SettingsSection title="Diagnostics">
|
||||
<SettingsSection title={t("settings.diagnostics.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Test audio</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.diagnostics.testAudio")}</Text>
|
||||
{playbackTestResult ? (
|
||||
<Text style={settingsStyles.rowHint}>{playbackTestResult}</Text>
|
||||
) : null}
|
||||
@@ -379,7 +459,9 @@ function DiagnosticsSection({
|
||||
onPress={handlePlayPress}
|
||||
disabled={!voiceAudioEngine || isPlaybackTestRunning}
|
||||
>
|
||||
{isPlaybackTestRunning ? "Playing..." : "Play test"}
|
||||
{isPlaybackTestRunning
|
||||
? t("settings.diagnostics.playing")
|
||||
: t("settings.diagnostics.playTest")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -394,14 +476,15 @@ interface AboutSectionProps {
|
||||
}
|
||||
|
||||
function AboutSection({ appVersion, appVersionText, isDesktopApp }: AboutSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="About">
|
||||
<SettingsSection title={t("settings.about.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>App version</Text>
|
||||
<Text style={settingsStyles.rowHint}>This device</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.about.appVersion")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.about.thisDevice")}</Text>
|
||||
</View>
|
||||
<Text style={styles.aboutValue}>{appVersionText}</Text>
|
||||
</View>
|
||||
@@ -423,12 +506,13 @@ function normalizeVersion(version: string | null | undefined): string | null {
|
||||
}
|
||||
|
||||
function ConnectedHostsSection({ clientVersion }: { clientVersion: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
const hosts = useHosts();
|
||||
if (hosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SettingsSection title="Connected hosts">
|
||||
<SettingsSection title={t("settings.about.connectedHosts")}>
|
||||
<View style={settingsStyles.card}>
|
||||
{hosts.map((host, index) => (
|
||||
<HostVersionRow
|
||||
@@ -452,6 +536,7 @@ function HostVersionRow({
|
||||
showBorder: boolean;
|
||||
clientVersion: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(host.serverId);
|
||||
const daemonVersion = useSessionStore(
|
||||
(state) => state.sessions[host.serverId]?.serverInfo?.version ?? null,
|
||||
@@ -469,7 +554,7 @@ function HostVersionRow({
|
||||
|
||||
let valueText: string;
|
||||
if (!isConnected) {
|
||||
valueText = "Offline";
|
||||
valueText = t("settings.about.offline");
|
||||
} else if (normalizedHost) {
|
||||
valueText = formatVersionWithPrefix(normalizedHost);
|
||||
} else {
|
||||
@@ -488,7 +573,7 @@ function HostVersionRow({
|
||||
{host.label}
|
||||
</Text>
|
||||
{isMismatch ? (
|
||||
<Text style={settingsStyles.rowHint}>Version differs from this device</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.about.versionDiffers")}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={valueStyle}>{valueText}</Text>
|
||||
@@ -497,15 +582,21 @@ function HostVersionRow({
|
||||
}
|
||||
|
||||
function getUpdateButtonLabel(
|
||||
t: TFunction,
|
||||
isInstalling: boolean,
|
||||
latestVersion: string | null | undefined,
|
||||
): string {
|
||||
if (isInstalling) return "Installing...";
|
||||
if (latestVersion) return `Update to ${formatVersionWithPrefix(latestVersion)}`;
|
||||
return "Update";
|
||||
if (isInstalling) return t("settings.about.updates.installing");
|
||||
if (latestVersion) {
|
||||
return t("settings.about.updates.updateTo", {
|
||||
version: formatVersionWithPrefix(latestVersion),
|
||||
});
|
||||
}
|
||||
return t("settings.about.updates.update");
|
||||
}
|
||||
|
||||
function DesktopAppUpdateRow() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const {
|
||||
isDesktopApp,
|
||||
@@ -541,6 +632,13 @@ function DesktopAppUpdateRow() {
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
const releaseChannelOptions = useMemo(
|
||||
() => [
|
||||
{ value: "stable" as const, label: t("settings.about.releaseChannel.stable") },
|
||||
{ value: "beta" as const, label: t("settings.about.releaseChannel.beta") },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleInstallUpdate = useCallback(() => {
|
||||
if (!isDesktopApp) {
|
||||
@@ -548,10 +646,10 @@ function DesktopAppUpdateRow() {
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: "Install desktop update",
|
||||
message: "This updates Paseo on this computer",
|
||||
confirmLabel: "Install update",
|
||||
cancelLabel: "Cancel",
|
||||
title: t("settings.about.updates.installTitle"),
|
||||
message: t("settings.about.updates.installMessage"),
|
||||
confirmLabel: t("settings.about.updates.installConfirm"),
|
||||
cancelLabel: t("common.actions.cancel"),
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
@@ -562,9 +660,12 @@ function DesktopAppUpdateRow() {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to open app update confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the update confirmation dialog.");
|
||||
Alert.alert(
|
||||
t("settings.about.updates.alertTitle"),
|
||||
t("settings.about.updates.alertMessage"),
|
||||
);
|
||||
});
|
||||
}, [installUpdate, isDesktopApp]);
|
||||
}, [installUpdate, isDesktopApp, t]);
|
||||
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
@@ -574,25 +675,27 @@ function DesktopAppUpdateRow() {
|
||||
<>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Release channel</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.about.releaseChannel.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Switch to Beta to get updates sooner and help shape them
|
||||
{t("settings.about.releaseChannel.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={settings.releaseChannel}
|
||||
onValueChange={handleReleaseChannelChange}
|
||||
options={RELEASE_CHANNEL_OPTIONS}
|
||||
options={releaseChannelOptions}
|
||||
/>
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>App updates</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.about.updates.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{statusText}</Text>
|
||||
{availableUpdate?.latestVersion ? (
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
|
||||
{t("settings.about.updates.readyToInstall", {
|
||||
version: formatVersionWithPrefix(availableUpdate.latestVersion),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
|
||||
@@ -604,7 +707,7 @@ function DesktopAppUpdateRow() {
|
||||
onPress={handleCheckForUpdates}
|
||||
disabled={isChecking || isInstalling}
|
||||
>
|
||||
{isChecking ? "Checking..." : "Check"}
|
||||
{isChecking ? t("settings.about.updates.checking") : t("settings.about.updates.check")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -612,7 +715,7 @@ function DesktopAppUpdateRow() {
|
||||
onPress={handleInstallUpdate}
|
||||
disabled={isChecking || isInstalling || !availableUpdate}
|
||||
>
|
||||
{getUpdateButtonLabel(isInstalling, availableUpdate?.latestVersion)}
|
||||
{getUpdateButtonLabel(t, isInstalling, availableUpdate?.latestVersion)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -760,6 +863,7 @@ interface SidebarProjectsButtonProps {
|
||||
|
||||
function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||
const labelStyle = useMemo(
|
||||
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
|
||||
@@ -778,7 +882,7 @@ function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonPr
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
Projects
|
||||
{t("settings.projects")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -805,6 +909,7 @@ function HostPickerOption({
|
||||
onPress,
|
||||
}: HostPickerOptionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const leadingSlot = useMemo(
|
||||
() => <Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||
@@ -815,10 +920,10 @@ function HostPickerOption({
|
||||
() =>
|
||||
isLocal ? (
|
||||
<Text style={sidebarStyles.localMarker} testID="settings-host-local-marker">
|
||||
Local
|
||||
{t("settings.hostPicker.local")}
|
||||
</Text>
|
||||
) : undefined,
|
||||
[isLocal],
|
||||
[isLocal, t],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
@@ -835,13 +940,14 @@ function HostPickerOption({
|
||||
|
||||
function AddHostOption({ active, onPress }: { active: boolean; onPress: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const leadingSlot = useMemo(
|
||||
() => <Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
label="Add host"
|
||||
label={t("settings.addHost")}
|
||||
leadingSlot={leadingSlot}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
@@ -872,6 +978,7 @@ function HostPicker({
|
||||
onAddHost,
|
||||
}: HostPickerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const triggerRef = useRef<View | null>(null);
|
||||
const activeHost =
|
||||
@@ -879,8 +986,8 @@ function HostPicker({
|
||||
|
||||
const options = useMemo<ComboboxOption[]>(() => {
|
||||
const hostOptions = sortedHosts.map((host) => ({ id: host.serverId, label: host.label }));
|
||||
return [...hostOptions, { id: ADD_HOST_OPTION_ID, label: "Add host" }];
|
||||
}, [sortedHosts]);
|
||||
return [...hostOptions, { id: ADD_HOST_OPTION_ID, label: t("settings.addHost") }];
|
||||
}, [sortedHosts, t]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
@@ -938,12 +1045,12 @@ function HostPicker({
|
||||
style={triggerStyle}
|
||||
onPress={handleOpen}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Switch host"
|
||||
accessibilityLabel={t("settings.hostPicker.switchHost")}
|
||||
testID="settings-host-picker"
|
||||
>
|
||||
<Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={sidebarStyles.pickerTriggerLabel} numberOfLines={1}>
|
||||
{activeHost?.label ?? "Host"}
|
||||
{activeHost?.label ?? t("settings.groups.host")}
|
||||
</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
@@ -953,7 +1060,7 @@ function HostPicker({
|
||||
onSelect={handleSelect}
|
||||
renderOption={renderOption}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
title={t("settings.hostPicker.switchHost")}
|
||||
desktopMinWidth={240}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
@@ -987,6 +1094,7 @@ function SettingsSidebar({
|
||||
layout,
|
||||
}: SettingsSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const hosts = useHosts();
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
const sortedHosts = useSortedHosts(hosts, localServerId);
|
||||
@@ -1012,12 +1120,12 @@ function SettingsSidebar({
|
||||
const sidebarBody = (
|
||||
<>
|
||||
<View style={sidebarStyles.list}>
|
||||
<Text style={sidebarStyles.groupLabel}>App</Text>
|
||||
<Text style={sidebarStyles.groupLabel}>{t("settings.groups.app")}</Text>
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.id}>
|
||||
<SidebarSectionButton
|
||||
itemId={item.id}
|
||||
label={item.label}
|
||||
label={t(item.labelKey)}
|
||||
icon={item.icon}
|
||||
isSelected={selectedSectionId === item.id}
|
||||
onSelect={onSelectSection}
|
||||
@@ -1031,7 +1139,7 @@ function SettingsSidebar({
|
||||
<SidebarSeparator />
|
||||
{hasHosts ? (
|
||||
<View style={sidebarStyles.list}>
|
||||
<Text style={sidebarStyles.groupLabel}>Host</Text>
|
||||
<Text style={sidebarStyles.groupLabel}>{t("settings.groups.host")}</Text>
|
||||
<HostPicker
|
||||
activeServerId={activeHostServerId}
|
||||
sortedHosts={sortedHosts}
|
||||
@@ -1043,7 +1151,7 @@ function SettingsSidebar({
|
||||
<SidebarHostSectionButton
|
||||
key={item.id}
|
||||
itemId={item.id}
|
||||
label={item.label}
|
||||
label={t(item.labelKey)}
|
||||
icon={item.icon}
|
||||
isSelected={selectedHostSection === item.id}
|
||||
onSelect={onSelectHostSection}
|
||||
@@ -1054,14 +1162,14 @@ function SettingsSidebar({
|
||||
<View style={sidebarStyles.list}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Add host"
|
||||
accessibilityLabel={t("settings.addHost")}
|
||||
onPress={onAddHost}
|
||||
testID="settings-add-host"
|
||||
style={sidebarItemStyle}
|
||||
>
|
||||
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={sidebarStyles.label} numberOfLines={1}>
|
||||
Add host
|
||||
{t("settings.addHost")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -1078,7 +1186,7 @@ function SettingsSidebar({
|
||||
{padding.top > 0 ? <View style={paddingTopStyle} /> : null}
|
||||
<SidebarHeaderRow
|
||||
icon={ArrowLeft}
|
||||
label="Back"
|
||||
label={t("settings.backToWorkspace")}
|
||||
onPress={onBackToWorkspace}
|
||||
testID="settings-back-to-workspace"
|
||||
/>
|
||||
@@ -1105,6 +1213,7 @@ export interface SettingsScreenProps {
|
||||
export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
const router = useRouter();
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
|
||||
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
||||
@@ -1167,6 +1276,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(language: AppLanguage) => {
|
||||
void updateSettings({ language });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleTerminalScrollbackLinesChange = useCallback(
|
||||
(terminalScrollbackLines: number) => {
|
||||
void updateSettings({ terminalScrollbackLines });
|
||||
@@ -1197,11 +1313,11 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error("[Settings] Playback test failed", error);
|
||||
setPlaybackTestResult(`Playback failed: ${message}`);
|
||||
setPlaybackTestResult(t("settings.diagnostics.playbackFailed", { message }));
|
||||
} finally {
|
||||
setIsPlaybackTestRunning(false);
|
||||
}
|
||||
}, [isPlaybackTestRunning, voiceAudioEngine]);
|
||||
}, [isPlaybackTestRunning, t, voiceAudioEngine]);
|
||||
|
||||
const closeAddConnectionFlow = useCallback(() => {
|
||||
setIsAddHostMethodVisible(false);
|
||||
@@ -1341,15 +1457,15 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
if (view.kind === "host") {
|
||||
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
if (!item) return null;
|
||||
return { title: item.label, Icon: item.icon };
|
||||
return { title: t(item.labelKey), Icon: item.icon };
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
if (!item) return null;
|
||||
return { title: item.label, Icon: item.icon };
|
||||
return { title: t(item.labelKey), Icon: item.icon };
|
||||
}
|
||||
if (view.kind === "project" || view.kind === "projects") {
|
||||
return { title: "Projects", Icon: FolderGit2 };
|
||||
return { title: t("settings.projects"), Icon: FolderGit2 };
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
@@ -1373,6 +1489,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
isDesktopApp={isDesktopApp}
|
||||
handleSendBehaviorChange={handleSendBehaviorChange}
|
||||
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
||||
handleLanguageChange={handleLanguageChange}
|
||||
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
||||
/>
|
||||
);
|
||||
@@ -1411,7 +1528,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
if (settingsLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>Loading settings...</Text>
|
||||
<Text style={styles.loadingText}>{t("settings.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1444,7 +1561,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
if (isCompactLayout && view.kind === "root") {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Settings" onBack={handleBackToWorkspace} />
|
||||
<BackHeader title={t("settings.title")} onBack={handleBackToWorkspace} />
|
||||
<ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
|
||||
<SettingsSidebar
|
||||
view={view}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type TextStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { HighlightToken } from "@getpaseo/highlight";
|
||||
@@ -134,6 +135,7 @@ function markerStyle(type: RowType) {
|
||||
// UnistylesRuntime.updateTheme commits a setting; the optional `overrides` layer
|
||||
// inline styles for live-while-typing feedback on the code font.
|
||||
export function AppearancePreview({ overrides }: AppearancePreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => buildUnifiedRows(), []);
|
||||
const codeOverride = useMemo(() => buildCodeOverride(overrides), [overrides]);
|
||||
const codeStyle = useMemo(() => [styles.codeLine, codeOverride], [codeOverride]);
|
||||
@@ -149,7 +151,7 @@ export function AppearancePreview({ overrides }: AppearancePreviewProps) {
|
||||
return (
|
||||
<View
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel="Live preview of the syntax theme and code font"
|
||||
accessibilityLabel={t("settings.appearance.syntax.previewAccessibility")}
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
style={styles.card}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, TextInput, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, Monitor, Moon, Sun } from "lucide-react-native";
|
||||
@@ -49,16 +51,18 @@ const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
// Stored value -> displayed label. `auto` reads as "System" for the app theme.
|
||||
const THEME_LABELS: Record<AppSettings["theme"], string> = {
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
zinc: "Zinc",
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
auto: "System",
|
||||
};
|
||||
function getThemeLabel(t: TFunction, value: AppSettings["theme"]): string {
|
||||
const labelKeys: Record<AppSettings["theme"], string> = {
|
||||
light: "settings.appearance.theme.options.light",
|
||||
dark: "settings.appearance.theme.options.dark",
|
||||
zinc: "settings.appearance.theme.options.zinc",
|
||||
midnight: "settings.appearance.theme.options.midnight",
|
||||
claude: "settings.appearance.theme.options.claude",
|
||||
ghostty: "settings.appearance.theme.options.ghostty",
|
||||
auto: "settings.appearance.theme.options.auto",
|
||||
};
|
||||
return t(labelKeys[value]);
|
||||
}
|
||||
|
||||
const PRIMARY_THEMES: readonly AppSettings["theme"][] = ["light", "dark", "auto"];
|
||||
const DARK_VARIANT_THEMES: readonly AppSettings["theme"][] = [
|
||||
@@ -72,13 +76,10 @@ const DARK_VARIANT_THEMES: readonly AppSettings["theme"][] = [
|
||||
// those read as a bug, so show a human label in the placeholder instead.
|
||||
const BARE_DEFAULT_STACKS: ReadonlySet<string> = new Set(["normal", "monospace"]);
|
||||
|
||||
function resolveDefaultStackPlaceholder(stack: string): string {
|
||||
return BARE_DEFAULT_STACKS.has(stack) ? "System default" : stack;
|
||||
function resolveDefaultStackPlaceholder(t: TFunction, stack: string): string {
|
||||
return BARE_DEFAULT_STACKS.has(stack) ? t("settings.appearance.fonts.systemDefault") : stack;
|
||||
}
|
||||
|
||||
const UI_FONT_PLACEHOLDER = resolveDefaultStackPlaceholder(DEFAULT_UI_FONT_STACK);
|
||||
const MONO_FONT_PLACEHOLDER = resolveDefaultStackPlaceholder(DEFAULT_MONO_FONT_STACK);
|
||||
|
||||
// Local size string (digits only) -> preview override number. Empty/invalid
|
||||
// yields undefined so the preview falls back to the committed theme value.
|
||||
function sizeDraftToOverride(value: string): number | undefined {
|
||||
@@ -128,13 +129,14 @@ interface ThemeMenuItemProps {
|
||||
}
|
||||
|
||||
function ThemeMenuItem({ themeValue, selected, onChange }: ThemeMenuItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleSelect = useCallback(() => {
|
||||
onChange(themeValue);
|
||||
}, [onChange, themeValue]);
|
||||
const leading = useMemo(() => <ThemeLeading themeValue={themeValue} />, [themeValue]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leading}>
|
||||
{THEME_LABELS[themeValue]}
|
||||
{getThemeLabel(t, themeValue)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -145,18 +147,22 @@ interface ThemeRowProps {
|
||||
}
|
||||
|
||||
function ThemeRow({ value, onChange }: ThemeRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const selectedLabel = getThemeLabel(t, value);
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Theme</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.appearance.theme.title")}</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={dropdownTriggerStyle}
|
||||
accessibilityLabel={`Theme: ${THEME_LABELS[value]}`}
|
||||
accessibilityLabel={t("settings.appearance.theme.accessibilityLabel", {
|
||||
value: selectedLabel,
|
||||
})}
|
||||
>
|
||||
<ThemeLeading themeValue={value} />
|
||||
<Text style={styles.triggerText}>{THEME_LABELS[value]}</Text>
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
@@ -316,18 +322,26 @@ interface SyntaxRowProps {
|
||||
}
|
||||
|
||||
function SyntaxRow({ value, onChange }: SyntaxRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const selectedLabel = syntaxLabelForId(value);
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Highlight theme</Text>
|
||||
<Text style={settingsStyles.rowHint}>Colors for code, independent of the app theme</Text>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.appearance.syntax.highlightTheme")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.appearance.syntax.highlightThemeHint")}
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={dropdownTriggerStyle}
|
||||
accessibilityLabel={`Highlight theme: ${syntaxLabelForId(value)}`}
|
||||
accessibilityLabel={t("settings.appearance.syntax.highlightThemeAccessibility", {
|
||||
value: selectedLabel,
|
||||
})}
|
||||
>
|
||||
<Text style={styles.triggerText}>{syntaxLabelForId(value)}</Text>
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
@@ -350,8 +364,11 @@ function SyntaxRow({ value, onChange }: SyntaxRowProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AppearanceSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const showFontFamilyRows = !isNative;
|
||||
const uiFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_UI_FONT_STACK);
|
||||
const monoFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_MONO_FONT_STACK);
|
||||
|
||||
const [uiFontDraft, setUiFontDraft] = useState(settings.uiFontFamily);
|
||||
const [monoFontDraft, setMonoFontDraft] = useState(settings.monoFontFamily);
|
||||
@@ -455,19 +472,19 @@ export function AppearanceSection() {
|
||||
|
||||
return (
|
||||
<View>
|
||||
<SettingsSection title="Theme">
|
||||
<SettingsSection title={t("settings.appearance.theme.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<ThemeRow value={settings.theme} onChange={handleThemeChange} />
|
||||
</View>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Fonts">
|
||||
<SettingsSection title={t("settings.appearance.fonts.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
{showFontFamilyRows ? (
|
||||
<FontFamilyRow
|
||||
title="Interface font"
|
||||
hint="Used across the app. Leave empty for the system default"
|
||||
accessibilityLabel="Interface font family"
|
||||
placeholder={UI_FONT_PLACEHOLDER}
|
||||
title={t("settings.appearance.fonts.interfaceFont")}
|
||||
hint={t("settings.appearance.fonts.interfaceFontHint")}
|
||||
accessibilityLabel={t("settings.appearance.fonts.interfaceFontAccessibility")}
|
||||
placeholder={uiFontPlaceholder}
|
||||
value={settings.uiFontFamily}
|
||||
draft={uiFontDraft}
|
||||
withBorder={false}
|
||||
@@ -476,8 +493,8 @@ export function AppearanceSection() {
|
||||
/>
|
||||
) : null}
|
||||
<FontSizeRow
|
||||
title="Interface size"
|
||||
accessibilityLabel="Interface font size"
|
||||
title={t("settings.appearance.fonts.interfaceSize")}
|
||||
accessibilityLabel={t("settings.appearance.fonts.interfaceSizeAccessibility")}
|
||||
draft={uiSizeDraft}
|
||||
withBorder={showFontFamilyRows}
|
||||
onChangeDraft={handleUiSizeChange}
|
||||
@@ -485,10 +502,10 @@ export function AppearanceSection() {
|
||||
/>
|
||||
{showFontFamilyRows ? (
|
||||
<FontFamilyRow
|
||||
title="Code font"
|
||||
hint="Used in code, diffs, and the terminal output. Leave empty for the system default"
|
||||
accessibilityLabel="Code font family"
|
||||
placeholder={MONO_FONT_PLACEHOLDER}
|
||||
title={t("settings.appearance.fonts.codeFont")}
|
||||
hint={t("settings.appearance.fonts.codeFontHint")}
|
||||
accessibilityLabel={t("settings.appearance.fonts.codeFontAccessibility")}
|
||||
placeholder={monoFontPlaceholder}
|
||||
value={settings.monoFontFamily}
|
||||
draft={monoFontDraft}
|
||||
withBorder
|
||||
@@ -497,15 +514,15 @@ export function AppearanceSection() {
|
||||
/>
|
||||
) : null}
|
||||
<FontSizeRow
|
||||
title="Code size"
|
||||
accessibilityLabel="Code font size"
|
||||
title={t("settings.appearance.fonts.codeSize")}
|
||||
accessibilityLabel={t("settings.appearance.fonts.codeSizeAccessibility")}
|
||||
draft={codeSizeDraft}
|
||||
onChangeDraft={handleCodeSizeChange}
|
||||
onCommit={commitCodeSize}
|
||||
/>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Syntax">
|
||||
<SettingsSection title={t("settings.appearance.syntax.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<SyntaxRow value={settings.syntaxTheme} onChange={handleSyntaxThemeChange} />
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ChevronRight, Globe, Monitor, Pencil, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
@@ -32,15 +34,12 @@ import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { formatLatency } from "@/utils/latency";
|
||||
|
||||
const RESTART_CONFIRMATION_MESSAGE =
|
||||
"This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically.";
|
||||
|
||||
function formatHostConnectionLabel(connection: HostConnection): string {
|
||||
function formatHostConnectionLabel(connection: HostConnection, t: TFunction): string {
|
||||
if (connection.type === "relay") {
|
||||
return `Relay (${connection.relayEndpoint})`;
|
||||
return `${t("settings.host.badges.relay")} (${connection.relayEndpoint})`;
|
||||
}
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return `Local (${connection.path})`;
|
||||
return `${t("settings.host.badges.local")} (${connection.path})`;
|
||||
}
|
||||
return `TCP (${connection.endpoint})`;
|
||||
}
|
||||
@@ -48,18 +47,19 @@ function formatHostConnectionLabel(connection: HostConnection): string {
|
||||
function formatActiveConnectionBadge(
|
||||
activeConnection: { type: HostConnection["type"]; display: string } | null,
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
t: TFunction,
|
||||
): { icon: React.ReactNode; text: string } | null {
|
||||
if (!activeConnection) return null;
|
||||
if (activeConnection.type === "relay") {
|
||||
return {
|
||||
icon: <Globe size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
text: "Relay",
|
||||
text: t("settings.host.badges.relay"),
|
||||
};
|
||||
}
|
||||
if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") {
|
||||
return {
|
||||
icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
text: "Local",
|
||||
text: t("settings.host.badges.local"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -74,24 +74,24 @@ function formatDaemonVersionBadge(version: string | null): string | null {
|
||||
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
|
||||
}
|
||||
|
||||
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
|
||||
|
||||
function useHostProfile(serverId: string): HostProfile | null {
|
||||
const daemons = useHosts();
|
||||
return daemons.find((entry) => entry.serverId === serverId) ?? null;
|
||||
}
|
||||
|
||||
function HostNotFound() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View>
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Host not found</Text>
|
||||
<Text style={styles.emptyText}>{t("settings.host.notFound")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HostStatusBadges({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||
const daemonVersion = useSessionStore(
|
||||
@@ -122,7 +122,7 @@ function HostStatusBadges({ serverId }: { serverId: string }) {
|
||||
} else {
|
||||
statusPillBg = "rgba(161, 161, 170, 0.1)";
|
||||
}
|
||||
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme);
|
||||
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme, t);
|
||||
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
||||
|
||||
const statusPillStyle = useMemo(
|
||||
@@ -170,6 +170,7 @@ function HostConnectionError({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
export function HostConnectionsPage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const host = useHostProfile(serverId);
|
||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||
|
||||
@@ -182,7 +183,7 @@ export function HostConnectionsPage({ serverId }: { serverId: string }) {
|
||||
<HostConnectionError serverId={serverId} />
|
||||
<ConnectionsSection host={host} />
|
||||
{isLocalDaemon ? (
|
||||
<SettingsSection title="Pair devices">
|
||||
<SettingsSection title={t("settings.host.pairDevices.title")}>
|
||||
<PairDeviceRow />
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
@@ -191,6 +192,7 @@ export function HostConnectionsPage({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
export function HostAgentsPage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const host = useHostProfile(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
@@ -201,13 +203,13 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
|
||||
return (
|
||||
<View>
|
||||
{isConnected ? (
|
||||
<SettingsSection title="Agents">
|
||||
<SettingsSection title={t("settings.hostSections.agents")}>
|
||||
<InjectPaseoToolsCard serverId={serverId} />
|
||||
<AppendSystemPromptCard serverId={serverId} />
|
||||
</SettingsSection>
|
||||
) : (
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Connect to this host to manage agents</Text>
|
||||
<Text style={styles.emptyText}>{t("settings.host.agents.unavailable")}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -215,6 +217,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
export function HostWorkspacesPage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const host = useHostProfile(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
@@ -225,12 +228,12 @@ export function HostWorkspacesPage({ serverId }: { serverId: string }) {
|
||||
return (
|
||||
<View>
|
||||
{isConnected ? (
|
||||
<SettingsSection title="Workspaces">
|
||||
<SettingsSection title={t("settings.hostSections.workspaces")}>
|
||||
<AutoArchiveMergedWorkspacesCard serverId={serverId} />
|
||||
</SettingsSection>
|
||||
) : (
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Connect to this host to manage workspaces</Text>
|
||||
<Text style={styles.emptyText}>{t("settings.host.workspaces.unavailable")}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -284,6 +287,7 @@ export function HostSettingsPage({
|
||||
}
|
||||
|
||||
export function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { renameHost } = useHostMutations();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -307,7 +311,7 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
hitSlop={8}
|
||||
style={styles.identityEditButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Edit label"
|
||||
accessibilityLabel={t("settings.host.daemon.rename.editLabel")}
|
||||
testID="host-page-label-edit-button"
|
||||
>
|
||||
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
@@ -315,10 +319,10 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
|
||||
<AdaptiveRenameModal
|
||||
visible={isEditing}
|
||||
title="Rename host"
|
||||
title={t("settings.host.daemon.rename.title")}
|
||||
initialValue={host.label}
|
||||
placeholder="My Host"
|
||||
submitLabel="Save"
|
||||
placeholder={t("settings.host.daemon.rename.placeholder")}
|
||||
submitLabel={t("settings.host.daemon.rename.submit")}
|
||||
onClose={closeEditor}
|
||||
onSubmit={handleSubmit}
|
||||
testID="host-page-rename-modal"
|
||||
@@ -328,6 +332,7 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
}
|
||||
|
||||
function ConnectionsSection({ host }: { host: HostProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const { removeConnection } = useHostMutations();
|
||||
const snapshot = useHostRuntimeSnapshot(host.serverId);
|
||||
const probeByConnectionId = snapshot?.probeByConnectionId ?? new Map();
|
||||
@@ -336,13 +341,20 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
|
||||
title: string;
|
||||
} | null>(null);
|
||||
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
|
||||
const removeConnectionHeader = useMemo<SheetHeader>(
|
||||
() => ({ title: t("settings.host.connections.removeTitle") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleRequestRemove = useCallback((connection: HostConnection) => {
|
||||
setPendingRemoveConnection({
|
||||
connectionId: connection.id,
|
||||
title: formatHostConnectionLabel(connection),
|
||||
});
|
||||
}, []);
|
||||
const handleRequestRemove = useCallback(
|
||||
(connection: HostConnection) => {
|
||||
setPendingRemoveConnection({
|
||||
connectionId: connection.id,
|
||||
title: formatHostConnectionLabel(connection, t),
|
||||
});
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleCloseConfirm = useCallback(() => {
|
||||
if (isRemovingConnection) return;
|
||||
@@ -361,13 +373,16 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
|
||||
.then(() => setPendingRemoveConnection(null))
|
||||
.catch((error) => {
|
||||
console.error("[HostPage] Failed to remove connection", error);
|
||||
Alert.alert("Error", "Unable to remove connection");
|
||||
Alert.alert(
|
||||
t("settings.host.connections.removeErrorTitle"),
|
||||
t("settings.host.connections.removeErrorMessage"),
|
||||
);
|
||||
})
|
||||
.finally(() => setIsRemovingConnection(false));
|
||||
}, [pendingRemoveConnection, removeConnection, host.serverId]);
|
||||
}, [pendingRemoveConnection, removeConnection, host.serverId, t]);
|
||||
|
||||
return (
|
||||
<SettingsSection title="Connections">
|
||||
<SettingsSection title={t("settings.host.connections.title")}>
|
||||
<View style={settingsStyles.card} testID="host-page-connections-card">
|
||||
{host.connections.map((conn, index) => {
|
||||
const probe = probeByConnectionId.get(conn.id);
|
||||
@@ -387,13 +402,15 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
|
||||
|
||||
{pendingRemoveConnection ? (
|
||||
<AdaptiveModalSheet
|
||||
header={REMOVE_CONNECTION_HEADER}
|
||||
header={removeConnectionHeader}
|
||||
visible
|
||||
onClose={handleCloseConfirm}
|
||||
testID="remove-connection-confirm-modal"
|
||||
>
|
||||
<Text style={styles.confirmText}>
|
||||
Remove {pendingRemoveConnection.title}? This cannot be undone.
|
||||
{t("settings.host.connections.removeMessage", {
|
||||
name: pendingRemoveConnection.title,
|
||||
})}
|
||||
</Text>
|
||||
<View style={styles.confirmActions}>
|
||||
<Button
|
||||
@@ -403,7 +420,7 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
|
||||
onPress={handleCancelConfirm}
|
||||
disabled={isRemovingConnection}
|
||||
>
|
||||
Cancel
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -413,7 +430,7 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
|
||||
disabled={isRemovingConnection}
|
||||
testID="remove-connection-confirm"
|
||||
>
|
||||
Remove
|
||||
{t("settings.host.connections.removeAction")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
@@ -437,12 +454,13 @@ function ConnectionRow({
|
||||
latencyError: boolean;
|
||||
onRemove: (connection: HostConnection) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const title = formatHostConnectionLabel(connection);
|
||||
const title = formatHostConnectionLabel(connection, t);
|
||||
|
||||
const latencyText = (() => {
|
||||
if (latencyLoading) return "...";
|
||||
if (latencyError) return "Timeout";
|
||||
if (latencyError) return t("settings.host.connections.timeout");
|
||||
if (latencyMs != null) return formatLatency(latencyMs);
|
||||
return "—";
|
||||
})();
|
||||
@@ -479,7 +497,7 @@ function ConnectionRow({
|
||||
textStyle={destructiveTextStyle}
|
||||
onPress={handlePressRemove}
|
||||
>
|
||||
Remove
|
||||
{t("settings.host.connections.removeAction")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -491,6 +509,7 @@ const delay = (ms: number) =>
|
||||
});
|
||||
|
||||
function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const daemonClient = useHostRuntimeClient(host.serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(host.serverId);
|
||||
@@ -533,34 +552,34 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
setIsRestarting(false);
|
||||
if (!reconnected) {
|
||||
Alert.alert(
|
||||
"Unable to reconnect",
|
||||
`${host.label} did not come back online. Please verify it restarted.`,
|
||||
t("settings.host.daemon.restart.unableToReconnectTitle"),
|
||||
t("settings.host.daemon.restart.unableToReconnectMessage", { name: host.label }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [host.label, isHostConnected, waitForCondition]);
|
||||
}, [host.label, isHostConnected, t, waitForCondition]);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
if (!daemonClient) {
|
||||
Alert.alert(
|
||||
"Host unavailable",
|
||||
"This host is not connected. Wait for it to come online before restarting.",
|
||||
t("settings.host.daemon.restart.unavailableTitle"),
|
||||
t("settings.host.daemon.restart.unavailableMessage"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isHostConnected()) {
|
||||
Alert.alert(
|
||||
"Host offline",
|
||||
"This host is offline. Paseo reconnects automatically—wait until it's back online before restarting.",
|
||||
t("settings.host.daemon.restart.offlineTitle"),
|
||||
t("settings.host.daemon.restart.offlineMessage"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: `Restart ${host.label}`,
|
||||
message: RESTART_CONFIRMATION_MESSAGE,
|
||||
confirmLabel: "Restart",
|
||||
cancelLabel: "Cancel",
|
||||
title: t("settings.host.daemon.restart.confirmTitle", { name: host.label }),
|
||||
message: t("settings.host.daemon.restart.confirmMessage"),
|
||||
confirmLabel: t("settings.host.daemon.restart.confirm"),
|
||||
cancelLabel: t("common.actions.cancel"),
|
||||
destructive: true,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
@@ -573,8 +592,8 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
if (!isMountedRef.current) return;
|
||||
setIsRestarting(false);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online.",
|
||||
t("settings.host.daemon.restart.requestFailedTitle"),
|
||||
t("settings.host.daemon.restart.requestFailedMessage"),
|
||||
);
|
||||
});
|
||||
void waitForDaemonRestart();
|
||||
@@ -582,9 +601,12 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error);
|
||||
Alert.alert("Error", "Unable to open the restart confirmation dialog.");
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.restart.requestFailedTitle"),
|
||||
t("settings.host.daemon.restart.dialogFailedMessage"),
|
||||
);
|
||||
});
|
||||
}, [daemonClient, host.label, host.serverId, isHostConnected, waitForDaemonRestart]);
|
||||
}, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]);
|
||||
|
||||
const restartIcon = useMemo(
|
||||
() => <RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />,
|
||||
@@ -595,10 +617,8 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
<View style={settingsStyles.card} testID="host-page-restart-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Restart daemon</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Restarts the daemon process. The app will reconnect automatically
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.host.daemon.restart.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.host.daemon.restart.hint")}</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -608,7 +628,9 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
disabled={isRestarting || !daemonClient || !isConnected}
|
||||
testID="host-page-restart-button"
|
||||
>
|
||||
{isRestarting ? "Restarting..." : "Restart"}
|
||||
{isRestarting
|
||||
? t("settings.host.daemon.restart.restarting")
|
||||
: t("settings.host.daemon.restart.confirm")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -616,6 +638,7 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
}
|
||||
|
||||
function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
|
||||
@@ -636,15 +659,17 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
<View style={settingsStyles.card} testID="host-page-inject-mcp-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Enable Paseo tools</Text>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.host.orchestration.enableTools.title")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Agents will be able to manage worktrees, agents and schedules
|
||||
{t("settings.host.orchestration.enableTools.hint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={config?.mcp.injectIntoAgents !== false}
|
||||
onValueChange={handleValueChange}
|
||||
accessibilityLabel="Inject Paseo tools"
|
||||
accessibilityLabel={t("settings.host.orchestration.enableTools.accessibilityLabel")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -691,13 +716,17 @@ function AutoArchiveMergedWorkspacesCard({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const persistedPrompt = config?.appendSystemPrompt ?? "";
|
||||
const [draft, setDraft] = useState(persistedPrompt);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: "Append system prompt" }), []);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("settings.host.orchestration.systemPrompt.sheetTitle") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(persistedPrompt);
|
||||
@@ -740,8 +769,12 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
<View style={settingsStyles.card} testID="host-page-append-system-prompt-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>System prompt</Text>
|
||||
<Text style={settingsStyles.rowHint}>Adds a system prompt to all agents</Text>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.host.orchestration.systemPrompt.title")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.host.orchestration.systemPrompt.hint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -749,7 +782,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
onPress={handleOpen}
|
||||
testID="host-page-append-system-prompt-edit"
|
||||
>
|
||||
Edit
|
||||
{t("settings.host.orchestration.systemPrompt.edit")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -764,10 +797,10 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
>
|
||||
<SettingsTextAreaCard
|
||||
testID="host-page-append-system-prompt-input"
|
||||
accessibilityLabel="Append system prompt"
|
||||
accessibilityLabel={t("settings.host.orchestration.systemPrompt.accessibilityLabel")}
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder="Always keep replies concise."
|
||||
placeholder={t("settings.host.orchestration.systemPrompt.placeholder")}
|
||||
/>
|
||||
<View style={styles.appendPromptActions}>
|
||||
<Button
|
||||
@@ -777,7 +810,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
disabled={!hasChanges || isSaving}
|
||||
testID="host-page-append-system-prompt-reset"
|
||||
>
|
||||
Reset
|
||||
{t("settings.host.orchestration.systemPrompt.reset")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -786,7 +819,9 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
disabled={!hasChanges || isSaving}
|
||||
testID="host-page-append-system-prompt-save"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
{isSaving
|
||||
? t("settings.host.orchestration.systemPrompt.saving")
|
||||
: t("settings.host.orchestration.systemPrompt.save")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
@@ -796,6 +831,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
function PairDeviceRow() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
@@ -811,10 +847,8 @@ function PairDeviceRow() {
|
||||
testID="host-page-pair-device-row"
|
||||
>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Pair a device</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Scan a QR code or copy a link to connect your phone to this host
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.host.pairDevices.rowTitle")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.host.pairDevices.rowHint")}</Text>
|
||||
</View>
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
@@ -837,6 +871,7 @@ function RemoveHostSection({
|
||||
isLocalDaemon: boolean;
|
||||
onRemoved?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { removeHost } = useHostMutations();
|
||||
const { updateSettings } = useDesktopSettings();
|
||||
@@ -844,6 +879,14 @@ function RemoveHostSection({
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
const daemonStatus = daemonStatusData?.status ?? null;
|
||||
const removeHostHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: isLocalDaemon
|
||||
? t("settings.host.daemon.remove.localConfirmTitle")
|
||||
: t("settings.host.daemon.remove.title"),
|
||||
}),
|
||||
[isLocalDaemon, t],
|
||||
);
|
||||
|
||||
const destructiveTextStyle = useMemo(
|
||||
() => ({ color: theme.colors.destructive }),
|
||||
@@ -903,8 +946,10 @@ function RemoveHostSection({
|
||||
.catch((error) => {
|
||||
console.error("[HostPage] Failed to remove host", error);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
isLocalDaemon ? "Unable to remove localhost connection" : "Unable to remove host",
|
||||
t("settings.host.daemon.remove.errorTitle"),
|
||||
isLocalDaemon
|
||||
? t("settings.host.daemon.remove.localErrorMessage")
|
||||
: t("settings.host.daemon.remove.errorMessage"),
|
||||
);
|
||||
})
|
||||
.finally(() => setIsRemoving(false));
|
||||
@@ -916,35 +961,34 @@ function RemoveHostSection({
|
||||
removeHost,
|
||||
rollbackLocalhostRemoval,
|
||||
setStatus,
|
||||
t,
|
||||
updateSettings,
|
||||
]);
|
||||
|
||||
const confirmationHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: isLocalDaemon ? "Remove localhost connection and stop daemon?" : "Remove host",
|
||||
}),
|
||||
[isLocalDaemon],
|
||||
);
|
||||
|
||||
const removeIcon = useMemo(
|
||||
() => <Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />,
|
||||
[theme.iconSize.sm, theme.colors.destructive],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection title="Danger zone" testID="host-page-remove-host-card">
|
||||
<SettingsSection
|
||||
title={t("settings.host.daemon.dangerZone")}
|
||||
testID="host-page-remove-host-card"
|
||||
>
|
||||
<RestartDaemonCard host={host} />
|
||||
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{isLocalDaemon ? "Remove localhost connection" : "Remove host"}
|
||||
{isLocalDaemon
|
||||
? t("settings.host.daemon.remove.localTitle")
|
||||
: t("settings.host.daemon.remove.title")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{isLocalDaemon
|
||||
? "Removes localhost from this device and stops the built-in daemon"
|
||||
: "Removes this host and its saved connections from this device"}
|
||||
? t("settings.host.daemon.remove.localHint")
|
||||
: t("settings.host.daemon.remove.hint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
@@ -955,22 +999,22 @@ function RemoveHostSection({
|
||||
onPress={handleOpenConfirm}
|
||||
testID="host-page-remove-host-button"
|
||||
>
|
||||
Remove
|
||||
{t("settings.host.connections.removeAction")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isConfirming ? (
|
||||
<AdaptiveModalSheet
|
||||
header={confirmationHeader}
|
||||
header={removeHostHeader}
|
||||
visible
|
||||
onClose={handleCloseConfirm}
|
||||
testID="remove-host-confirm-modal"
|
||||
>
|
||||
<Text style={styles.confirmText}>
|
||||
{isLocalDaemon
|
||||
? "This will remove the localhost connection, turn off built-in daemon management, and stop the managed daemon. Remote hosts remain connected."
|
||||
: `Remove ${host.label}? This will delete its saved connections.`}
|
||||
? t("settings.host.daemon.remove.localConfirmMessage")
|
||||
: t("settings.host.daemon.remove.confirmMessage", { name: host.label })}
|
||||
</Text>
|
||||
<View style={styles.confirmActions}>
|
||||
<Button
|
||||
@@ -980,7 +1024,7 @@ function RemoveHostSection({
|
||||
onPress={handleCancel}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
Cancel
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -990,7 +1034,7 @@ function RemoveHostSection({
|
||||
disabled={isRemoving}
|
||||
testID="remove-host-confirm"
|
||||
>
|
||||
Remove
|
||||
{t("settings.host.connections.removeAction")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text } from "react-native";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -32,6 +33,7 @@ function ShortcutSequence({
|
||||
chord: string[] | null;
|
||||
heldModifiers: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const displayChord = useMemo(() => {
|
||||
const combos = [...(chord ?? [])];
|
||||
if (heldModifiers) {
|
||||
@@ -41,7 +43,7 @@ function ShortcutSequence({
|
||||
}, [chord, heldModifiers]);
|
||||
|
||||
if ((!chord || chord.length === 0) && !heldModifiers) {
|
||||
return <Text style={styles.capturingText}>Press shortcut...</Text>;
|
||||
return <Text style={styles.capturingText}>{t("settings.shortcuts.capturePrompt")}</Text>;
|
||||
}
|
||||
|
||||
return <Shortcut chord={displayChord} />;
|
||||
@@ -119,6 +121,7 @@ function ShortcutRow({
|
||||
onCancel: () => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const displayChord = useMemo(
|
||||
() => (overrideCombo ? chordStringToShortcutKeys(overrideCombo) : [row.keys]),
|
||||
[overrideCombo, row.keys],
|
||||
@@ -127,7 +130,7 @@ function ShortcutRow({
|
||||
|
||||
return (
|
||||
<View style={rowStyle}>
|
||||
<Text style={styles.rowLabel}>{row.label}</Text>
|
||||
<Text style={styles.rowLabel}>{t(row.labelKey)}</Text>
|
||||
<View style={styles.rowActions}>
|
||||
{isCapturing ? (
|
||||
<ShortcutSequence chord={capturedCombos} heldModifiers={heldModifiers} />
|
||||
@@ -138,17 +141,19 @@ function ShortcutRow({
|
||||
<>
|
||||
{isCapturing && capturedCombos.length > 0 ? (
|
||||
<Button variant="ghost" size="sm" onPress={onDone}>
|
||||
Done
|
||||
{t("settings.shortcuts.actions.done")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="sm" onPress={isCapturing ? onCancel : onRebind}>
|
||||
{isCapturing ? "Cancel" : "Rebind"}
|
||||
{isCapturing
|
||||
? t("settings.shortcuts.actions.cancel")
|
||||
: t("settings.shortcuts.actions.rebind")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{overrideCombo !== undefined && !isCapturing && (
|
||||
<Button variant="ghost" size="sm" onPress={onReset}>
|
||||
<Text style={styles.resetText}>Reset</Text>
|
||||
<Text style={styles.resetText}>{t("settings.shortcuts.actions.reset")}</Text>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
@@ -157,6 +162,7 @@ function ShortcutRow({
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsSection() {
|
||||
const { t } = useTranslation();
|
||||
const [capturingBindingId, setCapturingBindingId] = useState<string | null>(null);
|
||||
const [capturedCombos, setCapturedCombos] = useState<string[]>([]);
|
||||
const [heldModifiers, setHeldModifiers] = useState<string | null>(null);
|
||||
@@ -244,9 +250,9 @@ export function KeyboardShortcutsSection() {
|
||||
|
||||
if (isNative) {
|
||||
return (
|
||||
<SettingsSection title="Shortcuts">
|
||||
<SettingsSection title={t("settings.sections.shortcuts")}>
|
||||
<View style={mobileCardStyle}>
|
||||
<Text style={styles.mobileText}>Keyboard shortcuts are only available on desktop</Text>
|
||||
<Text style={styles.mobileText}>{t("settings.shortcuts.unavailableOnMobile")}</Text>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
@@ -254,7 +260,7 @@ export function KeyboardShortcutsSection() {
|
||||
|
||||
const resetAllButton = hasOverrides ? (
|
||||
<Button variant="ghost" size="sm" onPress={handleResetAll}>
|
||||
Reset all
|
||||
{t("settings.shortcuts.actions.resetAll")}
|
||||
</Button>
|
||||
) : undefined;
|
||||
|
||||
@@ -264,7 +270,7 @@ export function KeyboardShortcutsSection() {
|
||||
return (
|
||||
<SettingsSection
|
||||
key={section.id}
|
||||
title={section.title}
|
||||
title={t(section.titleKey)}
|
||||
trailing={sectionIndex === 0 ? resetAllButton : undefined}
|
||||
>
|
||||
<View style={settingsStyles.card}>
|
||||
|
||||
@@ -100,6 +100,25 @@ vi.mock("lucide-react-native", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, string | number>) => {
|
||||
if (key === "settings.providers.providerDetails") return `${values?.name} provider details`;
|
||||
if (key === "settings.providers.enableProvider") return `Enable ${values?.name}`;
|
||||
if (key === "settings.providers.statuses.disabled") return "Disabled";
|
||||
if (key === "settings.providers.statuses.available") return "Available";
|
||||
if (key === "settings.providers.statuses.loading") return "Loading";
|
||||
if (key === "settings.providers.statuses.error") return "Error";
|
||||
if (key === "settings.providers.statuses.notInstalled") return "Not installed";
|
||||
if (key === "settings.providers.models.one") return "1 model";
|
||||
if (key === "settings.providers.models.many") return `${values?.count} models`;
|
||||
if (key === "settings.providers.addErrorTitle") return "Unable to add provider";
|
||||
if (key === "settings.providers.updateErrorTitle") return "Unable to update provider";
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/switch", () => ({
|
||||
Switch: ({
|
||||
value,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -29,18 +31,32 @@ interface ProviderStatus {
|
||||
modelCount: number | null;
|
||||
}
|
||||
|
||||
function getProviderStatus(status: string, enabled: boolean, modelCount: number): ProviderStatus {
|
||||
if (!enabled) return { tone: "muted", label: "Disabled", modelCount: null };
|
||||
if (status === "loading") return { tone: "loading", label: "Loading", modelCount: null };
|
||||
if (status === "error") return { tone: "danger", label: "Error", modelCount: null };
|
||||
function getProviderStatus(
|
||||
status: string,
|
||||
enabled: boolean,
|
||||
modelCount: number,
|
||||
t: TFunction,
|
||||
): ProviderStatus {
|
||||
if (!enabled)
|
||||
return { tone: "muted", label: t("settings.providers.statuses.disabled"), modelCount: null };
|
||||
if (status === "loading") {
|
||||
return { tone: "loading", label: t("settings.providers.statuses.loading"), modelCount: null };
|
||||
}
|
||||
if (status === "error") {
|
||||
return { tone: "danger", label: t("settings.providers.statuses.error"), modelCount: null };
|
||||
}
|
||||
if (status === "ready") {
|
||||
return {
|
||||
tone: "success",
|
||||
label: "Available",
|
||||
label: t("settings.providers.statuses.available"),
|
||||
modelCount: modelCount > 0 ? modelCount : null,
|
||||
};
|
||||
}
|
||||
return { tone: "warning", label: "Not installed", modelCount: null };
|
||||
return {
|
||||
tone: "warning",
|
||||
label: t("settings.providers.statuses.notInstalled"),
|
||||
modelCount: null,
|
||||
};
|
||||
}
|
||||
|
||||
interface ProviderRowProps {
|
||||
@@ -62,6 +78,7 @@ function ProviderRow({
|
||||
onPress,
|
||||
onToggleEnabled,
|
||||
}: ProviderRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const ProviderIcon = getProviderIcon(def.id);
|
||||
const providerError =
|
||||
@@ -72,7 +89,7 @@ function ProviderRow({
|
||||
? entry.error.trim()
|
||||
: null;
|
||||
const modelCount = entry.models?.length ?? 0;
|
||||
const providerStatus = getProviderStatus(entry.status, enabled, modelCount);
|
||||
const providerStatus = getProviderStatus(entry.status, enabled, modelCount, t);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(def.id);
|
||||
@@ -99,7 +116,7 @@ function ProviderRow({
|
||||
style={rowStyle}
|
||||
onPress={handlePress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${def.label} provider details`}
|
||||
accessibilityLabel={t("settings.providers.providerDetails", { name: def.label })}
|
||||
>
|
||||
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
|
||||
<>
|
||||
@@ -128,7 +145,7 @@ function ProviderRow({
|
||||
value={enabled}
|
||||
onValueChange={handleToggleValueChange}
|
||||
disabled={isToggling}
|
||||
accessibilityLabel={`Enable ${def.label}`}
|
||||
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -150,6 +167,7 @@ function getDotColor(tone: StatusTone, theme: ReturnType<typeof useUnistyles>["t
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: ProviderStatus }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const dotStyle = useMemo(
|
||||
() => [styles.statusDot, { backgroundColor: getDotColor(status.tone, theme) }],
|
||||
@@ -168,7 +186,9 @@ function StatusIndicator({ status }: { status: ProviderStatus }) {
|
||||
<>
|
||||
<Text style={styles.separator}>·</Text>
|
||||
<Text style={styles.statusLabel}>
|
||||
{status.modelCount === 1 ? "1 model" : `${status.modelCount} models`}
|
||||
{status.modelCount === 1
|
||||
? t("settings.providers.models.one")
|
||||
: t("settings.providers.models.many", { count: status.modelCount })}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
@@ -181,6 +201,7 @@ export interface ProvidersSectionProps {
|
||||
}
|
||||
|
||||
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
|
||||
const { patchConfig } = useDaemonConfig(serverId);
|
||||
@@ -205,14 +226,14 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
await patchConfig({ providers: { [providerId]: { enabled } } });
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
"Unable to update provider",
|
||||
t("settings.providers.updateErrorTitle"),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setPendingProviderId((current) => (current === providerId ? null : current));
|
||||
}
|
||||
},
|
||||
[patchConfig],
|
||||
[patchConfig, t],
|
||||
);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
@@ -224,31 +245,31 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
await refresh([entry.id]);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
"Unable to add provider",
|
||||
t("settings.providers.addErrorTitle"),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setInstallingProviderId((current) => (current === entry.id ? null : current));
|
||||
}
|
||||
},
|
||||
[installingProviderId, patchConfig, refresh],
|
||||
[installingProviderId, patchConfig, refresh, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title="Providers"
|
||||
title={t("settings.providers.title")}
|
||||
testID="host-page-providers-card"
|
||||
style={styles.sectionSpacing}
|
||||
>
|
||||
{!hasServer || !isConnected ? (
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Connect to this host to see providers</Text>
|
||||
<Text style={styles.emptyText}>{t("settings.providers.unavailable")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{hasServer && isConnected && isLoading ? (
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Loading...</Text>
|
||||
<Text style={styles.emptyText}>{t("settings.providers.loading")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? (
|
||||
@@ -275,7 +296,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
|
||||
{hasServer && isConnected ? (
|
||||
<SettingsSection
|
||||
title="Add provider"
|
||||
title={t("settings.providers.addProvider")}
|
||||
testID="host-page-add-provider-card"
|
||||
style={styles.addProviderSection}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -27,6 +28,7 @@ export function SettingsGroup({
|
||||
style,
|
||||
children,
|
||||
}: SettingsGroupProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const groupStyle = useMemo(() => [styles.group, style], [style]);
|
||||
return (
|
||||
@@ -39,7 +41,7 @@ export function SettingsGroup({
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`About ${title}`}
|
||||
accessibilityLabel={t("settings.groupInfo", { title })}
|
||||
testID={testID ? `${testID}-info` : undefined}
|
||||
hitSlop={8}
|
||||
style={styles.infoButton}
|
||||
|
||||
@@ -11,6 +11,7 @@ import Animated, {
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { BookOpen, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -297,6 +298,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const errorScrollViewStyle = useMemo(
|
||||
@@ -339,7 +341,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setDaemonLogs(null);
|
||||
setLogsError(`Unable to load daemon logs: ${message}`);
|
||||
setLogsError(t("startup.logs.loadFailed", { message }));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCancelled) {
|
||||
@@ -350,11 +352,11 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [isError]);
|
||||
}, [isError, t]);
|
||||
|
||||
const logsText = useMemo(() => {
|
||||
if (isLoadingLogs) {
|
||||
return "Loading daemon logs...";
|
||||
return t("startup.logs.loading");
|
||||
}
|
||||
if (daemonLogs?.contents) {
|
||||
return daemonLogs.contents;
|
||||
@@ -362,8 +364,8 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
if (logsError) {
|
||||
return logsError;
|
||||
}
|
||||
return "No daemon logs available.";
|
||||
}, [daemonLogs?.contents, isLoadingLogs, logsError]);
|
||||
return t("startup.logs.unavailable");
|
||||
}, [daemonLogs?.contents, isLoadingLogs, logsError, t]);
|
||||
|
||||
const handleCopyLogs = useCallback(() => {
|
||||
const payload = daemonLogs?.logPath
|
||||
@@ -409,13 +411,10 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
<View style={styles.errorContent}>
|
||||
<View style={styles.errorHeader}>
|
||||
<PaseoLogo size={64} />
|
||||
<Text style={styles.title}>Something went wrong</Text>
|
||||
<Text style={styles.title}>{t("startup.errorTitle")}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.errorDescription}>
|
||||
The local server failed to start. If this keeps happening, please report the issue on
|
||||
GitHub and include the logs below.
|
||||
</Text>
|
||||
<Text style={styles.errorDescription}>{t("startup.errorDescription")}</Text>
|
||||
|
||||
<Text dataSet={CODE_SURFACE_DATASET} style={styles.errorMessage}>
|
||||
{bootstrapState.splashError}
|
||||
|
||||
@@ -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