Complete client UI i18n migration

Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
chyendongnhanh338
2026-06-11 14:15:35 +08:00
committed by GitHub
parent 41cf070fd8
commit 08ebe5e7e1
199 changed files with 16426 additions and 1821 deletions
@@ -1,4 +1,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>