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

79
docs/i18n.md Normal file
View File

@@ -0,0 +1,79 @@
# I18n
Paseo client UI translations live in `packages/app/src/i18n`.
## Supported Locales
- `en`
- `ar`
- `es`
- `fr`
- `ru`
- `zh-CN`
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English.
## Translation Scope
Translate client-owned UI copy: labels, buttons, empty states, confirmation text, and local status/error wrappers.
Do not translate agent output, daemon output, terminal contents, file paths, provider names, model names, command names, user-authored text, code blocks, logs, or raw protocol/server error text.
## Adding Copy
English source strings live in `packages/app/src/i18n/resources/en.ts`. Simplified Chinese strings live in `packages/app/src/i18n/resources/zh-CN.ts`.
For migrated screens and components, use `useTranslation()` and pass translated text into UI primitives. Low-level primitives such as `<Button>` do not import translation state unless they own the text they render.
Keep resource keys grouped by product surface, not component mechanics.
Run:
```bash
npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
```
The parity test catches missing keys between English and Simplified Chinese resources.
## Migration Order
Client UI translation is staged so each pass can migrate complete local copy clusters and keep reviews focused.
1. App shell and shared UI chrome: common actions, headers, sheets, command center, and client-owned toast status.
2. Composer and agent workflow: composer input, agent controls, permission prompts, plan approval, and agent panel wrapper states.
3. Settings expansion: Appearance, Shortcuts, Integrations, Permissions, Diagnostics, About, Project settings, Host settings, and provider diagnostics.
4. Workspace and panels: setup/file/browser/terminal wrapper copy, file explorer local states, import-session flows, and remaining local toast/error wrapper text.
Within a migrated surface, do not leave mixed-language neighboring labels when those labels are owned by the client. Move the whole local copy cluster together.
### Progress
- Batch 2 migrated Composer and agent workflow chrome: Composer input and attachments, agent controls, stream permission prompts, agent panel wrapper states, and draft panel descriptors. Provider/model names, provider-defined option labels, agent output, and protocol/server diagnostics remain untranslated.
- Batch 3A migrated Settings Diagnostics/About, Appearance, Shortcuts, Integrations, and Desktop Permissions chrome. Host settings and Project settings remain for Batch 3B; raw runtime status/error details remain untranslated.
- Batch 3B migrated Host settings, Provider diagnostics, and Project settings chrome. Provider/model names, project/host labels, script commands, diagnostic output, file paths, and raw runtime/server error details remain untranslated.
- Batch 4A migrated workspace wrapper chrome for import sessions, file explorer, setup, browser, terminal, and file panels. File paths, URLs, commands, logs, terminal output, provider labels, and raw runtime/server errors remain untranslated.
- Batch 4B migrated workspace tab shell, workspace scripts, Git actions/diff/PR chrome, worktree archive warnings, Open-in-editor controls, and inline review controls. Branch names, PR titles/bodies, check names, workflow names, file paths, diff contents, commands, terminal output, provider labels, and raw runtime/server errors remain untranslated.
- Batch 4C migrated Sidebar project/workspace menus, hide/remove/archive confirmations, workspace rename chrome, New workspace ref picker/create flow, and Open project home tiles. Workspace/project names, branch names, PR titles, paths, provider labels, daemon output, and raw runtime/server errors remain untranslated.
- Batch 4D migrated provider/model selector chrome, provider catalog install modal, add-connection method modal, and paste-pairing-link modal. Provider/model/catalog names, provider descriptions, pairing URLs, protocol parser errors, daemon connection details, and raw runtime/server errors remain untranslated.
- Batch 4E migrated onboarding welcome chrome, direct-connection modal fields/actions/local failure guidance, QR scan permission/unavailable states, and desktop pair-device card. Pairing URLs, host/port placeholders, endpoint values, transport details, protocol parser errors, and raw runtime/server errors remain untranslated.
- Batch 4F migrated realtime voice overlay accessibility labels, rewind menu chrome and fallback toast, DiffViewer default empty state, and service URL chooser copy. Shortcut key names, raw daemon errors, diff contents, URLs, and caller-provided override labels remain untranslated.
- Batch 4G migrated the keyboard shortcuts help dialog to render section titles, row labels, and row notes through translation keys. Shortcut key names, shortcut combos, binding IDs, action IDs, and fallback registry labels remain untranslated.
- Batch 4H migrated Sessions screen chrome and AgentList local UI copy: date section headers, local status labels, fallback session titles, badges, load-more/empty states, and the archive action sheet. Agent titles, project paths, provider icons/labels, host labels, relative timestamps, and raw runtime data remain untranslated.
- Batch 4I migrated message utility chrome: image lightbox labels/errors, code and turn copy accessibility labels, dictation controls, question form fallback placeholders/actions, PlanCard fallback title, assistant image fallback errors, and todo list labels. Message bodies, plan text, todo item text, attachment labels, runtime dictation errors, and agent/tool output remain untranslated.
- Batch 4J migrated workspace tab toast/empty chrome: copy failure messages, copied labels, resume-command availability errors, reload-agent local status, host-disconnected wrapper reuse, and split-pane empty state. Agent IDs, generated resume commands, workspace paths, branch names, and raw reload errors remain untranslated.
- Batch 4K migrated sidebar/project list chrome: host picker fallback/search/title, footer actions and tooltips, Sessions row labels, mobile close label, New workspace tooltip/accessibility label, project-list empty states, and project settings host-load wrapper text. Host names, project names, workspace names, paths, branch names, and raw host errors remain untranslated.
- Batch 4L migrated picker/file/detail utility chrome: project picker states, branch switcher labels/placeholders, file pane loading/empty/fallback errors, tool-call details section/empty labels, and open-file accessibility. Directory paths, branch names, file contents, file sizes, tool details, and raw file-load errors remain untranslated.
- Batch 4M migrated hook/modal utility chrome: image attachment permissions/dialog/errors, copied toast wrappers, rename modal local validation/fallback errors, branch switcher stash/switch prompts and fallback toasts, and workspace setup local fallback errors. Copied labels supplied by callers, branch names, selected paths, raw dialog/API errors, and raw server errors remain untranslated.
- Batch 4N migrated pure view-model/policy utility chrome: import-session fallback titles/previews/empty states and the worktree setup callout. Provider labels and IDs remain runtime values interpolated into translated wrappers.
- Batch 4O migrated remaining small utility chrome: workspace route gate states/actions, compaction markers, archived-agent callout, web browser fallback, desktop quitting overlay, and image drop overlay. Host names, host status values, browser IDs, token counts, and raw route errors remain runtime values.
- Batch 4P migrated provider-selection pure view-model utility copy to direct `i18n.t(...)` calls and removed the local labels parameter path. Provider/model labels, provider IDs, and provider snapshot error messages remain runtime values.
- Batch 4Q migrated desktop update utility chrome: app update status text, update callout titles/actions, generic update errors, and install-error wrappers. Version labels, installer messages, raw update errors, release-channel data, and logs remain runtime values.
- Batch 4R migrated desktop permission utility chrome: permission status details, permission request fallback errors, empty permission statuses, and desktop notification test wrappers. Browser permission states, exception names, and raw browser API error messages remain runtime values.
- Batch 4S migrated desktop daemon settings chrome: built-in daemon status rows, daemon lifecycle toggles, logs/status modals, clipboard alerts, daemon management confirmations/errors, daemon status load errors, and desktop CLI/skills install wrapper errors. PIDs, log paths, log contents, CLI status output, version values, and raw IPC errors remain runtime values.
- Batch 4T migrated remaining attachment/autocomplete utility chrome: user and composer review attachment labels, workspace hover-card accessibility, branch stash restore prompts/toasts, agent autocomplete loading/empty/fallback error text, older-history fallback toast, draft panel labels, and agent-control fallback labels. PR/issue numbers, browser element tags, branch names, provider labels, model labels, agent prompts, command/file names, and raw server errors remain runtime values.
- Batch 4U migrated shared default utility chrome: Combobox and Autocomplete default placeholders/empty/loading labels, drag-overlay and subagent-track loading labels, sub-agent activity fallback headers, and file-preview fallback errors. Caller-provided labels, tab titles, subagent descriptions, file paths, and raw file-load errors remain runtime values.
- Batch 4V migrated Git policy action chrome to direct `i18n.t(...)` calls: commit/pull/push/sync/PR/merge/auto-merge/archive action labels, pending/success labels, and unavailable reasons. Branch/base refs, PR URLs, GitHub merge-state enum values, runtime statuses, and raw Git/GitHub errors remain runtime values.
- Batch 4W migrated remaining local wrapper states: workspace copy unavailable toasts, startup daemon-log loading/empty/load-failed text, and file-explorer workspace/host unavailable fallbacks. Workspace paths, branch names, daemon log contents/paths, checkout query details, and raw file/daemon errors remain runtime values.
- Batch 4X migrated descriptor and command chrome: Pair-device modal header, workspace setup sheet title, terminal panel fallback labels, command-center action titles, and file-pane host-disconnected fallback. Provider/catalog names, command-center search keywords, terminal runtime titles, file paths, and raw read errors remain runtime values.
- Batch 4Y tightened the translation boundary so React components and custom hooks use `useTranslation()` while pure helpers keep direct `i18n.t(...)` fallbacks, and migrated remaining small UI/accessibility fallbacks across message details, menu backdrops, startup errors, sidebar PR badges, settings/project accessibility labels, composer send/create/download fallbacks, client slash-command descriptions, terminal subscribe errors, and desktop update completion text. Provider catalog metadata, shortcut registry fallbacks, agent/daemon/protocol reasons, terminal contents, raw runtime errors, and user/project/workspace names remain untranslated.
- Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch.

103
package-lock.json generated
View File

@@ -2567,9 +2567,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -20529,6 +20529,19 @@
"react-native": "*"
}
},
"node_modules/expo-localization": {
"version": "17.0.9",
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.9.tgz",
"integrity": "sha512-k5eYHr7iLMob3M8cHH6bgDrDRmqnZG8xKGLhpx8ST+LsFXmSgYpJ/t0VwWm0gz64C/K669XhObdG3D6QwCGqhw==",
"license": "MIT",
"dependencies": {
"rtl-detect": "^1.0.2"
},
"peerDependencies": {
"expo": "*",
"react": "*"
}
},
"node_modules/expo-manifests": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz",
@@ -23838,6 +23851,15 @@
"node": ">=12"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -24016,6 +24038,34 @@
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause"
},
"node_modules/i18next": {
"version": "26.3.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/iconv-corefoundation": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
@@ -31270,6 +31320,33 @@
"react": ">=17.0.0"
}
},
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
@@ -32650,6 +32727,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/rtl-detect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
"license": "BSD-3-Clause"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -35233,7 +35316,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -36118,6 +36201,15 @@
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
"license": "MIT"
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
@@ -37026,6 +37118,7 @@
"expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",
"expo-localization": "~17.0.9",
"expo-notifications": "^0.32.16",
"expo-router": "~6.0.13",
"expo-sharing": "^14.0.8",
@@ -37033,12 +37126,14 @@
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"fast-deep-equal": "^3.1.3",
"i18next": "^26.3.0",
"lucide-react-native": "^0.546.0",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^17.0.8",
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",

View File

@@ -34,7 +34,7 @@ export async function editWorktreeSetup(page: Page, setupCommands: string[]): Pr
}
export async function clickSaveProjectSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save project config" }).click();
await page.getByRole("button", { name: "Save" }).click();
}
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
@@ -79,7 +79,7 @@ export async function expectWriteFailedCalloutActions(page: Page): Promise<void>
}
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Save project config" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Save" })).toBeDisabled();
}
// --- Form-state assertions ---

View File

@@ -0,0 +1,23 @@
import { test, expect } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { openSettingsSection } from "./helpers/settings";
test("Settings language selector switches General labels", async ({ page }) => {
test.setTimeout(120_000);
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "general");
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
await page.getByRole("button", { name: "System", exact: true }).click();
await page.getByRole("button", { name: "Simplified Chinese", exact: true }).click();
await expect(page.getByText("默认发送", { exact: true }).first()).toBeVisible();
await page.getByRole("button", { name: "简体中文", exact: true }).click();
await page.getByRole("button", { name: "English", exact: true }).click();
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
});

View File

@@ -73,6 +73,7 @@
"expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",
"expo-localization": "~17.0.9",
"expo-notifications": "^0.32.16",
"expo-router": "~6.0.13",
"expo-sharing": "^14.0.8",
@@ -80,12 +81,14 @@
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"fast-deep-equal": "^3.1.3",
"i18next": "^26.3.0",
"lucide-react-native": "^0.546.0",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^17.0.8",
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",

View File

@@ -10,6 +10,7 @@ import React, {
type ComponentProps,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import {
View,
Text,
@@ -157,6 +158,7 @@ function renderStreamItemWithTurnFooter(input: {
function renderListEmptyComponent(input: {
renderModel: AgentStreamRenderModel;
emptyStateStyle: StyleProp<ViewStyle>;
emptyText: string;
}): ReactNode {
if (
input.renderModel.boundary.hasVirtualizedHistory ||
@@ -170,7 +172,7 @@ function renderListEmptyComponent(input: {
return (
<View style={input.emptyStateStyle}>
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
<Text style={stylesheet.emptyStateText}>{input.emptyText}</Text>
</View>
);
}
@@ -245,6 +247,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
},
ref,
) {
const { t } = useTranslation();
const viewportRef = useRef<StreamViewportHandle | null>(null);
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
@@ -637,8 +640,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const listEmptyComponent = useMemo(
() => renderListEmptyComponent({ renderModel, emptyStateStyle }),
[renderModel, emptyStateStyle],
() =>
renderListEmptyComponent({
renderModel,
emptyStateStyle,
emptyText: t("agentStream.empty"),
}),
[renderModel, emptyStateStyle, t],
);
const { boundary, auxiliary } = renderModel;
@@ -745,7 +753,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
accessibilityRole="button"
accessibilityLabel="Scroll to bottom"
accessibilityLabel={t("agentStream.scrollToBottom")}
testID="scroll-to-bottom-button"
>
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
@@ -906,11 +914,14 @@ function PermissionRequestCard({
permission: PendingPermission;
client: DaemonClient | null;
}) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const { request } = permission;
const isPlanRequest = request.kind === "plan";
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
const title = isPlanRequest
? t("agentStream.permission.plan")
: (request.title ?? request.name ?? t("agentStream.permission.required"));
const description = request.description ?? "";
const resolvedToolCallDetail = useMemo(
() =>
@@ -931,19 +942,21 @@ function PermissionRequestCard({
return [
{
id: "reject",
label: "Deny",
label: t("agentStream.permission.deny"),
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "accept",
label: isPlanRequest ? "Implement" : "Accept",
label: isPlanRequest
? t("agentStream.permission.implement")
: t("agentStream.permission.accept"),
behavior: "allow",
variant: "primary",
},
];
}, [isPlanRequest, request]);
}, [isPlanRequest, request, t]);
const planMarkdown = useMemo(() => {
if (!request) {
@@ -968,7 +981,7 @@ function PermissionRequestCard({
response: AgentPermissionResponse;
}) => {
if (!client) {
throw new Error("Daemon client unavailable");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return client.respondToPermissionAndWait(
input.agentId,
@@ -1042,7 +1055,7 @@ function PermissionRequestCard({
const footer = (
<>
<Text testID="permission-request-question" style={permissionStyles.question}>
How would you like to proceed?
{t("agentStream.permission.question")}
</Text>
<View style={optionsContainerStyle}>
@@ -1094,7 +1107,7 @@ function PermissionRequestCard({
{planMarkdown ? (
<PlanCard
title="Proposed plan"
title={t("agentStream.permission.proposedPlan")}
text={planMarkdown}
testID="permission-plan-card"
disableOuterSpacing

View File

@@ -69,6 +69,7 @@ import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { I18nProvider } from "@/i18n/provider";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
@@ -934,13 +935,15 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
function RootProviders({ children }: { children: ReactNode }) {
return (
<QueryProvider>
<SafeAreaProvider>
<KeyboardProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardProvider>
</SafeAreaProvider>
<I18nProvider>
<SafeAreaProvider>
<KeyboardProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</I18nProvider>
</QueryProvider>
);
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -120,6 +121,7 @@ function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
export default function PairScanScreen() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const params = useLocalSearchParams<{
@@ -190,13 +192,13 @@ export default function PairScanScreen() {
navigateToPairedHost(profile.serverId);
} catch (error) {
lastScannedRef.current = null;
const message = error instanceof Error ? error.message : "Unable to pair host";
Alert.alert("Error", message);
const message = error instanceof Error ? error.message : t("pairing.scan.unableToPair");
Alert.alert(t("pairing.scan.errorTitle"), message);
} finally {
setIsPairing(false);
}
},
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
[isPairing, navigateToPairedHost, t, upsertDaemonFromOfferUrl],
);
const handleRouterBack = useCallback(() => router.back(), [router]);
@@ -216,15 +218,13 @@ export default function PairScanScreen() {
if (isWeb) {
return (
<View style={styles.container}>
<BackHeader title="Scan QR" onBack={handleRouterBack} />
<BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
<View style={bodyStyle}>
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Not available on web</Text>
<Text style={styles.permissionBody}>
{`QR scanning isn't supported in the web build. Use "Paste link" instead.`}
</Text>
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
<Pressable style={styles.permissionButton} onPress={closeToSource}>
<Text style={styles.permissionButtonText}>Back to Settings</Text>
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
</Pressable>
</View>
</View>
@@ -236,17 +236,15 @@ export default function PairScanScreen() {
return (
<View style={styles.container}>
<BackHeader title="Scan QR" onBack={closeToSource} />
<BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
<View style={bodyStyle}>
{!granted ? (
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Camera permission</Text>
<Text style={styles.permissionBody}>
Allow camera access to scan the pairing QR code from your daemon.
</Text>
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</Text>
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
<Text style={styles.permissionButtonText}>Grant permission</Text>
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
</Pressable>
</View>
) : (
@@ -264,7 +262,7 @@ export default function PairScanScreen() {
<View style={CORNER_BL_STYLE} />
<View style={CORNER_BR_STYLE} />
</View>
{isPairing ? <Text style={helperTextStyle}>Pairing</Text> : null}
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
</View>
</View>
)}

View File

@@ -4,6 +4,7 @@ import {
type AssistantFileLinkClassification,
type InlinePathTarget,
} from "./parse";
import { i18n } from "@/i18n/i18next";
export interface AssistantFileLinkSource {
href: string;
@@ -60,7 +61,7 @@ export interface FetchDaemonResolutionInput {
export class UnresolvedFileLinkError extends Error {
constructor(readonly token: string) {
super(`No file found for ${token}`);
super(i18n.t("common.errors.noFileFound", { token }));
this.name = "UnresolvedFileLinkError";
}
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
@@ -11,7 +12,6 @@ import {
import {
classifyForResolution,
fetchDaemonResolution,
UnresolvedFileLinkError,
type AssistantFileLinkResolution,
type AssistantFileLinkSource,
} from "./resolver";
@@ -40,6 +40,7 @@ type AssistantFileLinkQueryKey = readonly [
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
const { t } = useTranslation();
const context = useAssistantFileLinkResolverContext();
const queryClient = useQueryClient();
const stableSource = useStableSource(source);
@@ -91,6 +92,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
disposition,
context,
queryClient,
formatNoFileFoundMessage: (token) => t("common.errors.noFileFound", { token }),
});
},
);
@@ -163,6 +165,7 @@ function openAssistantFileLink(input: {
disposition: OpenFileDisposition;
context: AssistantFileLinkResolverContextValue;
queryClient: ReturnType<typeof useQueryClient>;
formatNoFileFoundMessage: (token: string) => string;
}): void {
const capturedConfig = input.context.configRef.current;
const capturedResolution = classifyForResolution(input.source, {
@@ -211,7 +214,7 @@ function openAssistantFileLink(input: {
} catch (error) {
await dispatchUnresolvedError({
error,
fallbackToken: capturedResolution.token,
noFileFoundMessage: input.formatNoFileFoundMessage(capturedResolution.token),
capturedServerId: capturedConfig.serverId,
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
context: input.context,
@@ -322,7 +325,7 @@ async function dispatchExternalUrl(input: {
async function dispatchUnresolvedError(input: {
error: unknown;
fallbackToken: string;
noFileFoundMessage: string;
capturedServerId?: string;
capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue;
@@ -334,9 +337,7 @@ async function dispatchUnresolvedError(input: {
) {
return;
}
const token =
input.error instanceof UnresolvedFileLinkError ? input.error.token : input.fallbackToken;
current.toast?.show(`No file found for ${token}`, {
current.toast?.show(input.noFileFoundMessage, {
variant: "error",
testID: "assistant-file-link-not-found-toast",
});

View File

@@ -8,6 +8,7 @@ export interface ClientSlashCommand {
name: string;
aliases: readonly string[];
description: string;
descriptionKey: "composer.clientCommands.archiveAgent" | "composer.clientCommands.freshDraft";
argumentHint: string;
kind: ClientSlashCommandKind;
execution: ClientSlashCommandExecution;
@@ -18,6 +19,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
name: "exit",
aliases: ["quit", "q"],
description: "Archive the current agent",
descriptionKey: "composer.clientCommands.archiveAgent",
argumentHint: "",
kind: "archive-agent",
execution: "immediate",
@@ -26,6 +28,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
name: "clear",
aliases: ["new"],
description: "Archive this agent and start a fresh draft",
descriptionKey: "composer.clientCommands.freshDraft",
argumentHint: "",
kind: "replace-agent-with-draft",
execution: "immediate",

View File

@@ -1,6 +1,7 @@
import { forwardRef, useCallback, useEffect, useMemo } from "react";
import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
@@ -304,6 +305,7 @@ export function SheetHeaderView({
testID?: string;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
@@ -327,7 +329,7 @@ export function SheetHeaderView({
hitSlop={8}
style={styles.headerBackButton}
accessibilityRole="button"
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")}
testID="sheet-header-back"
>
{({ pressed }) => (
@@ -347,7 +349,11 @@ export function SheetHeaderView({
</View>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
{showCloseButton ? (
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<Pressable
accessibilityLabel={t("common.actions.close")}
style={styles.closeButton}
onPress={onClose}
>
{({ pressed }) => (
<X
size={16}
@@ -363,7 +369,7 @@ export function SheetHeaderView({
<AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={search.placeholder ?? "Search"}
placeholder={search.placeholder ?? t("common.actions.search")}
resetKey={search.resetKey}
onChangeText={handleSearchChange}
autoCapitalize="none"
@@ -379,6 +385,7 @@ export function SheetHeaderView({
export function InlineHeaderView({ header }: { header: SheetHeader }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const back = header.back;
const handleBackPress = back?.onPress;
const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions);
@@ -393,7 +400,9 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
hitSlop={8}
style={styles.headerBackButton}
accessibilityRole="button"
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
accessibilityLabel={
back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")
}
testID="sheet-header-back"
>
{({ pressed }) => (
@@ -417,7 +426,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
<AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={header.search.placeholder ?? "Search"}
placeholder={header.search.placeholder ?? t("common.actions.search")}
resetKey={header.search.resetKey}
onChangeText={header.search.onChange}
autoCapitalize="none"
@@ -462,6 +471,7 @@ export function AdaptiveModalSheet({
presentation,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
@@ -585,7 +595,11 @@ export function AdaptiveModalSheet({
const desktopContent = (
<View style={styles.desktopOverlay} testID={testID}>
<Pressable accessibilityLabel="Dismiss" style={ABSOLUTE_FILL_STYLE} onPress={onClose} />
<Pressable
accessibilityLabel={t("common.actions.dismiss")}
style={ABSOLUTE_FILL_STYLE}
onPress={onClose}
/>
<View style={desktopCardStyle}>
{onFilesDropped ? (
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>

View File

@@ -1,12 +1,11 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { isNative } from "@/constants/platform";
const ADD_CONNECTION_HEADER: SheetHeader = { title: "Add connection" };
const styles = StyleSheet.create((theme) => ({
option: {
flexDirection: "row",
@@ -49,6 +48,8 @@ export function AddHostMethodModal({
onPasteLink,
}: AddHostMethodModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.connectionMethods.title") }), [t]);
const handleDirect = useCallback(() => {
onDirectConnection();
@@ -64,7 +65,7 @@ export function AddHostMethodModal({
return (
<AdaptiveModalSheet
header={ADD_CONNECTION_HEADER}
header={header}
visible={visible}
onClose={onClose}
testID="add-host-method-modal"
@@ -73,13 +74,15 @@ export function AddHostMethodModal({
style={styles.option}
onPress={handleDirect}
accessibilityRole="button"
accessibilityLabel="Direct connection"
accessibilityLabel={t("pairing.connectionMethods.direct.title")}
testID="add-host-method-direct"
>
<Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Direct connection</Text>
<Text style={styles.optionSubtext}>Local network or VPN.</Text>
<Text style={styles.optionText}>{t("pairing.connectionMethods.direct.title")}</Text>
<Text style={styles.optionSubtext}>
{t("pairing.connectionMethods.direct.description")}
</Text>
</View>
</Pressable>
@@ -88,12 +91,14 @@ export function AddHostMethodModal({
style={styles.option}
onPress={handleScan}
accessibilityRole="button"
accessibilityLabel="Scan QR code"
accessibilityLabel={t("pairing.connectionMethods.scanQr.title")}
>
<QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Scan QR code</Text>
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
<Text style={styles.optionText}>{t("pairing.connectionMethods.scanQr.title")}</Text>
<Text style={styles.optionSubtext}>
{t("pairing.connectionMethods.scanQr.description")}
</Text>
</View>
</Pressable>
) : null}
@@ -102,13 +107,15 @@ export function AddHostMethodModal({
style={styles.option}
onPress={handlePaste}
accessibilityRole="button"
accessibilityLabel="Paste pairing link"
accessibilityLabel={t("pairing.connectionMethods.pasteLink.title")}
testID="add-host-method-pair-link"
>
<ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Paste pairing link</Text>
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
<Text style={styles.optionText}>{t("pairing.connectionMethods.pasteLink.title")}</Text>
<Text style={styles.optionSubtext}>
{t("pairing.connectionMethods.pasteLink.description")}
</Text>
</View>
</Pressable>
</AdaptiveModalSheet>

View File

@@ -1,4 +1,5 @@
import { useCallback, useMemo, useReducer, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -15,7 +16,6 @@ import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adapt
import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const DIRECT_CONNECTION_HEADER: SheetHeader = { title: "Direct connection" };
interface DirectConnectionDraft {
host: string;
@@ -31,6 +31,20 @@ interface PreparedDirectConnection {
password?: string;
}
interface DirectConnectionLabels {
hostRequired: string;
invalidPort: string;
invalidConnection: string;
failedToConnect: (endpoint: string) => string;
noAdditionalDetails: (detail: string) => string;
timedOut: string;
refused: string;
hostNotFound: string;
hostUnreachable: string;
tlsError: string;
unableToConnect: string;
}
const styles = StyleSheet.create((theme) => ({
field: {
gap: theme.spacing[2],
@@ -128,14 +142,17 @@ function isIpv6Host(host: string): boolean {
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
}
function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string {
function buildConnectionUriFromDraft(
draft: DirectConnectionDraft,
labels: DirectConnectionLabels,
): string {
const host = draft.host.trim();
const port = Number(draft.port.trim());
if (!host) {
throw new Error("Host is required");
throw new Error(labels.hostRequired);
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Port must be between 1 and 65535");
throw new Error(labels.invalidPort);
}
return serializeConnectionUriForStorage({
@@ -147,8 +164,11 @@ function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string {
});
}
function prepareDirectConnection(draft: DirectConnectionDraft): PreparedDirectConnection {
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft));
function prepareDirectConnection(
draft: DirectConnectionDraft,
labels: DirectConnectionLabels,
): PreparedDirectConnection {
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels));
const endpoint = parsed.isIpv6
? `[${parsed.host}]:${parsed.port}`
: `${parsed.host}:${parsed.port}`;
@@ -178,7 +198,10 @@ function normalizeTransportMessage(message: string | null | undefined): string |
return trimmed;
}
function formatTechnicalTransportDetails(details: (string | null)[]): string | null {
function formatTechnicalTransportDetails(
details: (string | null)[],
labels: DirectConnectionLabels,
): string | null {
const unique = Array.from(
new Set(
details
@@ -197,22 +220,24 @@ function formatTechnicalTransportDetails(details: (string | null)[]): string | n
});
if (allGeneric) {
return `${unique[0]} (no additional details provided)`;
return labels.noAdditionalDetails(unique[0] ?? "");
}
return unique.join(" — ");
}
function buildConnectionFailureCopy(
endpoint: string,
error: unknown,
): { title: string; detail: string | null; raw: string | null } {
const title = `We failed to connect to ${endpoint}.`;
function buildConnectionFailureCopy(input: {
endpoint: string;
error: unknown;
labels: DirectConnectionLabels;
}): { title: string; detail: string | null; raw: string | null } {
const { endpoint, error, labels } = input;
const title = labels.failedToConnect(endpoint);
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError]) ??
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
normalizeTransportMessage(error.message)
);
}
@@ -228,26 +253,25 @@ function buildConnectionFailureCopy(
if (raw === "Incorrect password" || raw === "Password required") {
detail = raw;
} else if (rawLower.includes("timed out")) {
detail = "Connection timed out. Check the host/port and your network.";
detail = labels.timedOut;
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = "Connection refused. Is the server running at this address?";
detail = labels.refused;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = "Host not found. Check the hostname and try again.";
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = "Host is unreachable. Check your network and firewall.";
detail = labels.hostUnreachable;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail =
"TLS error. Direct connections use SSL only when a TLS terminator is in front of the daemon.";
detail = labels.tlsError;
} else {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
detail = labels.unableToConnect;
}
return { title, detail, raw };
@@ -267,6 +291,7 @@ export interface AddHostModalProps {
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor();
@@ -308,6 +333,23 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
() => ({ checked: useTls, disabled: isSaving }),
[isSaving, useTls],
);
const directConnectionLabels = useMemo<DirectConnectionLabels>(
() => ({
hostRequired: t("pairing.direct.errors.hostRequired"),
invalidPort: t("pairing.direct.errors.invalidPort"),
invalidConnection: t("pairing.direct.errors.invalidConnection"),
failedToConnect: (endpoint) => t("pairing.direct.errors.failedToConnect", { endpoint }),
noAdditionalDetails: (detail) => t("pairing.direct.errors.noAdditionalDetails", { detail }),
timedOut: t("pairing.direct.errors.timedOut"),
refused: t("pairing.direct.errors.refused"),
hostNotFound: t("pairing.direct.errors.hostNotFound"),
hostUnreachable: t("pairing.direct.errors.hostUnreachable"),
tlsError: t("pairing.direct.errors.tlsError"),
unableToConnect: t("pairing.direct.errors.unableToConnect"),
}),
[t],
);
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.direct.title") }), [t]);
const handleClose = useCallback(() => {
if (isSaving) return;
@@ -328,9 +370,13 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
let connection: PreparedDirectConnection;
try {
connection = prepareDirectConnection({ host, port, useTls, password });
connection = prepareDirectConnection(
{ host, port, useTls, password },
directConnectionLabels,
);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid connection";
const message =
error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
setErrorMessage(message);
return;
}
@@ -349,10 +395,20 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(connection.uri, error);
const {
title,
detail,
raw: rawDetail,
} = buildConnectionFailureCopy({
endpoint: connection.uri,
error,
labels: directConnectionLabels,
});
let combined: string;
if (rawDetail && detail && rawDetail !== detail) {
combined = `${title}\n${detail}\nDetails: ${rawDetail}`;
combined = `${title}\n${detail}\n${t("pairing.direct.errors.details", {
detail: rawDetail,
})}`;
} else if (detail) {
combined = `${title}\n${detail}`;
} else {
@@ -360,13 +416,14 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}
setErrorMessage(combined);
if (!isMobile) {
Alert.alert("Connection failed", combined);
Alert.alert(t("pairing.direct.errors.failedTitle"), combined);
}
} finally {
setIsSaving(false);
}
}, [
daemons,
directConnectionLabels,
handleClose,
host,
isMobile,
@@ -375,6 +432,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
password,
port,
probeAndUpsertDirectConnection,
t,
useTls,
]);
@@ -398,7 +456,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const handleToggleAdvanced = useCallback(() => {
if (!isAdvancedOpen) {
try {
setAdvancedUri(buildConnectionUriFromDraft({ host, port, useTls, password }));
setAdvancedUri(
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
);
} catch {
setAdvancedUri("");
}
@@ -419,27 +479,27 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setErrorMessage("");
}
setIsAdvancedOpen(false);
}, [advancedUri, host, isAdvancedOpen, password, port, useTls]);
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
return (
<AdaptiveModalSheet
header={DIRECT_CONNECTION_HEADER}
header={header}
visible={visible}
onClose={handleClose}
testID="add-host-modal"
>
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
<Text style={styles.helper}>{t("pairing.direct.helper")}</Text>
<View style={styles.portRow}>
<View style={hostFieldStyle}>
<Text style={styles.label}>Host</Text>
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
<AdaptiveTextInput
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="Host"
accessibilityLabel={t("pairing.direct.fields.host")}
initialValue={host}
resetKey={`direct-host-${inputResetKey}`}
value={host}
@@ -455,11 +515,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
/>
</View>
<View style={portFieldStyle}>
<Text style={styles.label}>Port</Text>
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
<AdaptiveTextInput
testID="direct-port-input"
nativeID="direct-port-input"
accessibilityLabel="Port"
accessibilityLabel={t("pairing.direct.fields.port")}
initialValue={port}
resetKey={`direct-port-${inputResetKey}`}
value={port}
@@ -482,7 +542,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onPress={handleToggleUseTls}
disabled={isSaving}
accessibilityRole="checkbox"
accessibilityLabel="Use SSL"
accessibilityLabel={t("pairing.direct.fields.useSsl")}
accessibilityState={useTlsAccessibilityState}
testID="direct-ssl-toggle"
>
@@ -493,21 +553,21 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
</View>
) : null}
</View>
<Text style={styles.label}>Use SSL</Text>
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
</Pressable>
<View style={styles.field}>
<Text style={styles.label}>Password</Text>
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
<View style={styles.passwordRow}>
<AdaptiveTextInput
testID="direct-password-input"
nativeID="direct-password-input"
accessibilityLabel="Password"
accessibilityLabel={t("pairing.direct.fields.password")}
initialValue={password}
resetKey={`direct-password-${inputResetKey}`}
value={password}
onChangeText={setPassword}
placeholder="Optional"
placeholder={t("pairing.direct.fields.optional")}
placeholderTextColor={theme.colors.foregroundMuted}
style={passwordInputStyle}
autoCapitalize="none"
@@ -522,7 +582,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={isPasswordVisible ? "Hide password" : "Show password"}
accessibilityLabel={
isPasswordVisible
? t("pairing.direct.passwordVisibility.hide")
: t("pairing.direct.passwordVisibility.show")
}
testID="direct-password-visibility-toggle"
>
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
@@ -536,17 +600,19 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onPress={handleToggleAdvanced}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={isAdvancedOpen ? "Hide advanced" : "Show advanced"}
accessibilityLabel={
isAdvancedOpen ? t("pairing.direct.advanced.hide") : t("pairing.direct.advanced.show")
}
testID="direct-host-advanced-toggle"
>
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.advancedText}>Advanced</Text>
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
</Pressable>
{isAdvancedOpen ? (
<AdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel="Connection URI"
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`}
value={advancedUri}
@@ -572,7 +638,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onPress={handleCancel}
disabled={isSaving}
>
Cancel
{t("pairing.direct.actions.cancel")}
</Button>
<Button
style={FLEX_ONE_STYLE}
@@ -582,7 +648,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
leftIcon={connectIcon}
testID="direct-host-submit"
>
{isSaving ? "Connecting..." : "Connect"}
{isSaving ? t("pairing.direct.actions.connecting") : t("pairing.direct.actions.connect")}
</Button>
</View>
</AdaptiveModalSheet>

View File

@@ -11,6 +11,8 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
@@ -33,8 +35,18 @@ interface AgentListProps {
showAttentionIndicator?: boolean;
}
type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older";
const DATE_SECTION_ORDER = [
"today",
"yesterday",
"thisWeek",
"thisMonth",
"older",
] as const satisfies readonly DateSectionKey[];
type FlatListItem =
| { type: "header"; key: string; title: string }
| { type: "header"; key: string; section: DateSectionKey }
| { type: "agent"; key: string; agent: AggregatedAgent };
function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent {
@@ -94,7 +106,7 @@ function rememberArchivedAgentDetail(agent: AggregatedAgent) {
});
}
function deriveDateSectionLabel(lastActivityAt: Date): string {
function deriveDateSectionKey(lastActivityAt: Date): DateSectionKey {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
@@ -105,35 +117,50 @@ function deriveDateSectionLabel(lastActivityAt: Date): string {
);
if (activityStart.getTime() >= todayStart.getTime()) {
return "Today";
return "today";
}
if (activityStart.getTime() >= yesterdayStart.getTime()) {
return "Yesterday";
return "yesterday";
}
const diffTime = todayStart.getTime() - activityStart.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7) {
return "This week";
return "thisWeek";
}
if (diffDays <= 30) {
return "This month";
return "thisMonth";
}
return "Older";
return "older";
}
function formatStatusLabel(status: AggregatedAgent["status"]): string {
function formatDateSectionLabel(t: TFunction, section: DateSectionKey): string {
switch (section) {
case "today":
return t("agentList.dateSections.today");
case "yesterday":
return t("agentList.dateSections.yesterday");
case "thisWeek":
return t("agentList.dateSections.thisWeek");
case "thisMonth":
return t("agentList.dateSections.thisMonth");
case "older":
return t("agentList.dateSections.older");
}
}
function formatStatusLabel(t: TFunction, status: AggregatedAgent["status"]): string {
switch (status) {
case "initializing":
return "Starting";
return t("agentList.status.initializing");
case "idle":
return "Idle";
return t("agentList.status.idle");
case "running":
return "Running";
return t("agentList.status.running");
case "error":
return "Error";
return t("agentList.status.error");
case "closed":
return "Closed";
return t("agentList.status.closed");
default:
return status;
}
@@ -188,12 +215,14 @@ function SessionRow({
onLongPress: (agent: AggregatedAgent) => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const statusLabel = formatStatusLabel(agent.status);
const statusLabel = formatStatusLabel(t, agent.status);
const projectPath = shortenPath(agent.cwd);
const ProviderIcon = getProviderIcon(agent.provider);
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
@@ -231,14 +260,19 @@ function SessionRow({
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text style={sessionTitleStyle} numberOfLines={1}>
{agent.title || "New session"}
{agent.title || t("agentList.fallbackTitle")}
</Text>
{agent.archivedAt ? <SessionBadge label="Archived" icon={archivedIcon} /> : null}
{(agent.pendingPermissionCount ?? 0) > 0 ? (
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
{agent.archivedAt ? (
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
) : null}
{pendingPermissionCount > 0 ? (
<SessionBadge
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
tone="warning"
/>
) : null}
{!isMobile && showAttentionIndicator && agent.requiresAttention ? (
<SessionBadge label="Attention" tone="danger" />
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
) : null}
</View>
{isMobile && (
@@ -272,7 +306,7 @@ function SessionRow({
)}
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
<View style={styles.rowTrailing}>
<SessionBadge label="Attention" tone="danger" />
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
</View>
) : null}
</Pressable>
@@ -289,6 +323,7 @@ export function AgentList({
showAttentionIndicator = true,
}: AgentListProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = useIsCompactFormFactor();
@@ -354,22 +389,21 @@ export function AgentList({
}, [actionAgent, actionClient, archiveAgent]);
const flatItems = useMemo((): FlatListItem[] => {
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
const buckets = new Map<string, AggregatedAgent[]>();
const buckets = new Map<DateSectionKey, AggregatedAgent[]>();
for (const agent of agents) {
const label = deriveDateSectionLabel(agent.lastActivityAt);
const existing = buckets.get(label) ?? [];
const section = deriveDateSectionKey(agent.lastActivityAt);
const existing = buckets.get(section) ?? [];
existing.push(agent);
buckets.set(label, existing);
buckets.set(section, existing);
}
const result: FlatListItem[] = [];
for (const label of order) {
const data = buckets.get(label);
for (const section of DATE_SECTION_ORDER) {
const data = buckets.get(section);
if (!data || data.length === 0) {
continue;
}
result.push({ type: "header", key: `header:${label}`, title: label });
result.push({ type: "header", key: `header:${section}`, section });
for (const agent of data) {
result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent });
}
@@ -382,7 +416,7 @@ export function AgentList({
if (item.type === "header") {
return (
<View style={styles.sectionHeading}>
<Text style={styles.sectionTitle}>{item.title}</Text>
<Text style={styles.sectionTitle}>{formatDateSectionLabel(t, item.section)}</Text>
</View>
);
}
@@ -397,7 +431,7 @@ export function AgentList({
/>
);
},
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator],
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator, t],
);
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
@@ -454,8 +488,8 @@ export function AgentList({
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>
{isActionDaemonUnavailable
? "Host offline"
: "This agent is still running. Archiving it will stop the agent."}
? t("agentList.archiveSheet.hostOffline")
: t("agentList.archiveSheet.runningAgent")}
</Text>
<View style={styles.sheetButtonRow}>
<Pressable
@@ -463,7 +497,7 @@ export function AgentList({
onPress={handleCloseActionSheet}
testID="agent-action-cancel"
>
<Text style={styles.sheetCancelText}>Cancel</Text>
<Text style={styles.sheetCancelText}>{t("common.actions.cancel")}</Text>
</Pressable>
<Pressable
disabled={isActionDaemonUnavailable}
@@ -471,7 +505,7 @@ export function AgentList({
onPress={handleArchiveAgent}
testID="agent-action-archive"
>
<Text style={sheetArchiveTextStyle}>Archive</Text>
<Text style={sheetArchiveTextStyle}>{t("agentList.archiveSheet.archive")}</Text>
</Pressable>
</View>
</View>

View File

@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -15,6 +16,7 @@ interface ArchivedAgentCalloutProps {
}
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -43,14 +45,14 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
<View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}>
<View style={styles.callout}>
<Text style={styles.calloutText}>This agent is archived</Text>
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
>
Unarchive
{t("agentPanel.archived.unarchive")}
</Button>
</View>
</View>

View File

@@ -55,6 +55,17 @@ vi.mock("@/constants/platform", () => ({
isNative: false,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) =>
({
"message.attachments.closeImage": "Close image",
"message.attachments.dismissImage": "Dismiss image",
"message.attachments.imageLoadFailed": "Couldn't load image",
})[key] ?? key,
}),
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));

View File

@@ -4,6 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Image as ExpoImage } from "expo-image";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { isWeb } from "@/constants/platform";
@@ -15,6 +16,7 @@ interface AttachmentLightboxProps {
export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const url = useAttachmentPreviewUrl(metadata);
const [errored, setErrored] = useState(false);
@@ -63,14 +65,14 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
<Pressable
testID="attachment-lightbox-backdrop"
accessibilityRole="button"
accessibilityLabel="Dismiss image"
accessibilityLabel={t("message.attachments.dismissImage")}
onPress={onClose}
style={styles.backdrop}
/>
<View style={styles.contentLayer}>
<View style={styles.imageArea}>
{hasError ? (
<Text style={styles.errorText}>Couldn&apos;t load image</Text>
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
) : (
<Pressable onPress={noopPress} style={styles.imagePressable}>
<ExpoImage
@@ -86,7 +88,7 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
<Pressable
testID="attachment-lightbox-close"
accessibilityRole="button"
accessibilityLabel="Close image"
accessibilityLabel={t("message.attachments.closeImage")}
hitSlop={8}
onPress={onClose}
style={closeButtonStyle}

View File

@@ -3,6 +3,7 @@ import { Pressable, View, type PressableStateCallbackType } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import type { ComboboxProps } from "@/components/ui/combobox";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -27,6 +28,7 @@ export function BranchSwitcher({
isGitCheckout,
}: BranchSwitcherProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const anchorRef = useRef<View>(null);
const client = useHostRuntimeClient(serverId);
@@ -91,7 +93,7 @@ export function BranchSwitcher({
onPress={handleOpen}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
accessibilityLabel={t("branchSwitcher.currentBranch", { branchName: currentBranchName })}
>
{titleContent}
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
@@ -101,10 +103,10 @@ export function BranchSwitcher({
value={currentBranchName}
onSelect={handleBranchSelect}
searchable
placeholder="Switch branch..."
searchPlaceholder="Filter branches..."
emptyText="No branches found."
title="Switch branch"
placeholder={t("branchSwitcher.placeholder")}
searchPlaceholder={t("branchSwitcher.searchPlaceholder")}
emptyText={t("branchSwitcher.empty")}
title={t("branchSwitcher.title")}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={anchorRef}

View File

@@ -10,6 +10,7 @@ import {
import { Pressable, Text, TextInput, View } from "react-native";
import { ArrowLeft, ArrowRight, MousePointer2, PencilRuler, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import {
buildWorkspaceAttachmentScopeKey,
useWorkspaceAttachments,
@@ -55,7 +56,7 @@ function truncateText(value: string, maxLength: number): string {
return value.length > maxLength ? `${value.slice(0, maxLength).trim()}...` : value;
}
function getWebviewLoadErrorMessage(event: Event): string | null {
function getWebviewLoadErrorMessage(event: Event, failedToLoadLabel: string): string | null {
const details = event as Event & {
errorCode?: unknown;
errorDescription?: unknown;
@@ -69,7 +70,7 @@ function getWebviewLoadErrorMessage(event: Event): string | null {
const description =
typeof details.errorDescription === "string" && details.errorDescription.trim()
? details.errorDescription.trim()
: "Failed to load page";
: failedToLoadLabel;
const url =
typeof details.validatedURL === "string" && details.validatedURL.trim()
? details.validatedURL.trim()
@@ -78,7 +79,7 @@ function getWebviewLoadErrorMessage(event: Event): string | null {
return url ? `${description}: ${url}` : description;
}
function getLoadUrlRejectionMessage(error: unknown): string | null {
function getLoadUrlRejectionMessage(error: unknown, failedToLoadLabel: string): string | null {
if (error instanceof Error && error.message.trim()) {
if (error.message.includes("ERR_ABORTED") || error.message.includes("ERR_BLOCKED_BY_CLIENT")) {
return null;
@@ -91,18 +92,21 @@ function getLoadUrlRejectionMessage(error: unknown): string | null {
}
return error.trim();
}
return "Failed to load page";
return failedToLoadLabel;
}
function getUnsafeNavigationMessage(url: string): string | null {
function getUnsafeNavigationMessage(
url: string,
labels: { invalidUrl: string; unsupportedProtocol: (protocol: string) => string },
): string | null {
try {
const parsed = new URL(url);
if (ALLOWED_BROWSER_PROTOCOLS.has(parsed.protocol) || parsed.href === "about:blank") {
return null;
}
return `Blocked unsupported browser URL: ${parsed.protocol}`;
return labels.unsupportedProtocol(parsed.protocol);
} catch {
return "Invalid browser URL";
return labels.invalidUrl;
}
}
@@ -283,6 +287,7 @@ export function BrowserPane({
onFocusPane?: () => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const browser = useBrowserStore((state) => state.browsersById[browserId] ?? null);
const updateBrowser = useBrowserStore((state) => state.updateBrowser);
const webviewRef = useRef<ElectronWebview | null>(null);
@@ -327,6 +332,17 @@ export function BrowserPane({
() => [styles.metaError, { color: theme.colors.palette.red[500] }],
[theme.colors.palette.red],
);
const browserErrorLabels = useMemo(
() => ({
failedToLoad: t("workspace.browser.errors.failedToLoad"),
invalidUrl: t("workspace.browser.errors.invalidUrl"),
unsupportedProtocol: (protocol: string) =>
t("workspace.browser.errors.unsupportedProtocol", { protocol }),
}),
[t],
);
const browserErrorLabelsRef = useRef(browserErrorLabels);
browserErrorLabelsRef.current = browserErrorLabels;
useEffect(() => {
const nextUrl = browser?.url ?? "https://example.com";
@@ -384,7 +400,10 @@ export function BrowserPane({
host.replaceChildren();
const initialUnsafeNavigationMessage = getUnsafeNavigationMessage(initialUrlRef.current);
const initialUnsafeNavigationMessage = getUnsafeNavigationMessage(
initialUrlRef.current,
browserErrorLabelsRef.current,
);
const webview = document.createElement("webview") as ElectronWebview;
webviewRef.current = webview;
webview.setAttribute("partition", `persist:paseo-browser-${browserId}`);
@@ -459,7 +478,7 @@ export function BrowserPane({
updateBrowserRef.current(browserIdRef.current, { faviconUrl: favicons[0] ?? null });
};
const handleLoadFailed = (event: Event) => {
const message = getWebviewLoadErrorMessage(event);
const message = getWebviewLoadErrorMessage(event, browserErrorLabelsRef.current.failedToLoad);
if (!message) {
return;
}
@@ -519,43 +538,46 @@ export function BrowserPane({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [browserId, onFocusPane]);
const navigate = useCallback((nextUrl: string) => {
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
const webview = webviewRef.current;
const unsafeNavigationMessage = getUnsafeNavigationMessage(normalizedUrl);
const previousUrl = browserRef.current?.url ?? initialUrlRef.current;
pendingNavigationUrlRef.current = unsafeNavigationMessage ? null : normalizedUrl;
updateBrowserRef.current(browserIdRef.current, {
url: normalizedUrl,
isLoading: unsafeNavigationMessage === null,
...(normalizedUrl !== previousUrl ? { faviconUrl: null } : {}),
lastError: null,
});
setDraftUrl((current) => (current === normalizedUrl ? current : normalizedUrl));
if (unsafeNavigationMessage) {
const navigate = useCallback(
(nextUrl: string) => {
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
const webview = webviewRef.current;
const unsafeNavigationMessage = getUnsafeNavigationMessage(normalizedUrl, browserErrorLabels);
const previousUrl = browserRef.current?.url ?? initialUrlRef.current;
pendingNavigationUrlRef.current = unsafeNavigationMessage ? null : normalizedUrl;
updateBrowserRef.current(browserIdRef.current, {
isLoading: false,
lastError: unsafeNavigationMessage,
url: normalizedUrl,
isLoading: unsafeNavigationMessage === null,
...(normalizedUrl !== previousUrl ? { faviconUrl: null } : {}),
lastError: null,
});
return;
}
if (webview?.loadURL) {
void webview.loadURL(normalizedUrl).catch((error: unknown) => {
const message = getLoadUrlRejectionMessage(error);
if (!message) {
return;
}
setDraftUrl((current) => (current === normalizedUrl ? current : normalizedUrl));
if (unsafeNavigationMessage) {
updateBrowserRef.current(browserIdRef.current, {
isLoading: false,
lastError: message,
lastError: unsafeNavigationMessage,
});
});
return;
}
if (webview) {
webview.setAttribute("src", normalizedUrl);
}
}, []);
return;
}
if (webview?.loadURL) {
void webview.loadURL(normalizedUrl).catch((error: unknown) => {
const message = getLoadUrlRejectionMessage(error, browserErrorLabels.failedToLoad);
if (!message) {
return;
}
updateBrowserRef.current(browserIdRef.current, {
isLoading: false,
lastError: message,
});
});
return;
}
if (webview) {
webview.setAttribute("src", normalizedUrl);
}
},
[browserErrorLabels],
);
const handleBack = useCallback(() => {
webviewRef.current?.goBack?.();
@@ -929,10 +951,8 @@ export function BrowserPane({
if (!isElectronRuntime()) {
return (
<View style={styles.unavailableState}>
<Text style={titleStyle}>Browser is desktop-only</Text>
<Text style={subtitleStyle}>
Open this workspace in Electron to use the built-in browser.
</Text>
<Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
<Text style={subtitleStyle}>{t("workspace.browser.unavailable.subtitle")}</Text>
</View>
);
}
@@ -943,7 +963,7 @@ export function BrowserPane({
<View style={styles.chromeLeft}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Back"
accessibilityLabel={t("workspace.browser.controls.back")}
disabled={!browser?.canGoBack}
onPress={handleBack}
style={backIconButtonStyle}
@@ -952,7 +972,7 @@ export function BrowserPane({
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel="Forward"
accessibilityLabel={t("workspace.browser.controls.forward")}
disabled={!browser?.canGoForward}
onPress={handleForward}
style={forwardIconButtonStyle}
@@ -961,7 +981,11 @@ export function BrowserPane({
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={browser?.isLoading ? "Stop loading" : "Refresh"}
accessibilityLabel={
browser?.isLoading
? t("workspace.browser.controls.stopLoading")
: t("workspace.browser.controls.refresh")
}
onPress={handleRefresh}
style={baseIconButtonStyle}
>
@@ -970,13 +994,13 @@ export function BrowserPane({
</View>
<View style={styles.urlBarWrap}>
<TextInput
accessibilityLabel="Browser URL"
accessibilityLabel={t("workspace.browser.controls.browserUrl")}
autoCapitalize="none"
autoCorrect={false}
onChangeText={setDraftUrl}
onFocus={handleUrlBarFocus}
onSubmitEditing={handleNavigateDraftUrl}
placeholder="Enter URL"
placeholder={t("workspace.browser.controls.enterUrl")}
placeholderTextColor={theme.colors.foregroundMuted}
ref={urlInputRef}
style={urlInputStyle}
@@ -988,7 +1012,7 @@ export function BrowserPane({
<>
<Pressable
accessibilityRole="button"
accessibilityLabel="Open browser dev tools"
accessibilityLabel={t("workspace.browser.controls.openDevTools")}
onPress={handleOpenDevTools}
style={baseIconButtonStyle}
>
@@ -996,7 +1020,11 @@ export function BrowserPane({
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={selectorActive ? "Cancel element selector" : "Select element"}
accessibilityLabel={
selectorActive
? t("workspace.browser.controls.cancelSelector")
: t("workspace.browser.controls.selectElement")
}
onPress={handleToggleElementSelector}
style={selectorIconButtonStyle}
>

View File

@@ -1,6 +1,7 @@
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
interface BrowserPaneProps {
browserId: string;
@@ -13,6 +14,7 @@ interface BrowserPaneProps {
export function BrowserPane({ browserId }: BrowserPaneProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
@@ -24,8 +26,8 @@ export function BrowserPane({ browserId }: BrowserPaneProps) {
return (
<View style={styles.container}>
<Text style={titleStyle}>Browser is desktop-only</Text>
<Text style={subtitleStyle}>Browser session {browserId}</Text>
<Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
<Text style={subtitleStyle}>{t("workspace.browser.session", { browserId })}</Text>
</View>
);
}

View File

@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
interface BrowserPaneProps {
@@ -12,6 +13,7 @@ interface BrowserPaneProps {
}
export function BrowserPane({ browserId }: BrowserPaneProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
@@ -24,11 +26,9 @@ export function BrowserPane({ browserId }: BrowserPaneProps) {
return (
<View style={styles.container}>
<Text style={titleStyle}>Browser is desktop-only</Text>
<Text style={subtitleStyle}>
Open this workspace in Electron to use the built-in browser.
</Text>
<Text style={subtitleStyle}>Browser session {browserId}</Text>
<Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
<Text style={subtitleStyle}>{t("workspace.browser.unavailable.subtitle")}</Text>
<Text style={subtitleStyle}>{t("workspace.browser.session", { browserId })}</Text>
</View>
);
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
View,
Text,
@@ -144,6 +145,7 @@ function ModelRow({
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const ProviderIcon = getProviderIcon(row.provider);
const handleToggleFavorite = useCallback(
@@ -166,7 +168,9 @@ function ModelRow({
hitSlop={8}
style={favoriteButtonStyle}
accessibilityRole="button"
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
accessibilityLabel={
isFavorite ? t("modelSelector.unfavoriteModel") : t("modelSelector.favoriteModel")
}
testID={`favorite-model-${row.provider}-${row.modelId}`}
>
{({ hovered }) => {
@@ -193,6 +197,7 @@ function ModelRow({
theme.colors.palette.amber,
theme.colors.foregroundMuted,
theme.colors.border,
t,
],
);
@@ -256,6 +261,7 @@ function FavoritesSection({
onSelect: (provider: string, modelId: string) => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { t } = useTranslation();
if (favoriteRows.length === 0) {
return null;
}
@@ -263,7 +269,7 @@ function FavoritesSection({
return (
<View style={styles.favoritesContainer}>
<View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>Favorites</Text>
<Text style={styles.sectionHeadingText}>{t("modelSelector.favorites")}</Text>
</View>
{favoriteRows.map((row) => (
<SelectableModelRow
@@ -295,6 +301,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const ProvIcon = getProviderIcon(provider.id);
const selection = provider.modelSelection;
@@ -306,7 +313,11 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
if (selection.kind === "models") {
const count = selection.rows.length;
stateNode = (
<Text style={styles.drillDownCount}>{`${count} ${count === 1 ? "model" : "models"}`}</Text>
<Text style={styles.drillDownCount}>
{t(count === 1 ? "modelSelector.modelCount" : "modelSelector.modelCountPlural", {
count,
})}
</Text>
);
} else if (selection.kind === "loading") {
stateNode = (
@@ -316,14 +327,14 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
color={theme.colors.foregroundMuted}
style={styles.rowSpinner}
/>
<Text style={styles.drillDownCount}>Loading</Text>
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
</View>
);
} else {
stateNode = (
<View style={styles.rowStateInline}>
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownCount}>Error</Text>
<Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
</View>
);
}
@@ -431,6 +442,7 @@ function ProviderErrorEmptyState({
isRetryingProvider: boolean;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const handleRetry = useCallback(() => {
onRetryProvider?.(providerId);
}, [onRetryProvider, providerId]);
@@ -440,7 +452,7 @@ function ProviderErrorEmptyState({
<Text style={styles.emptyStateText}>{message}</Text>
{onRetryProvider ? (
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
{isRetryingProvider ? "Retrying" : "Retry"}
{isRetryingProvider ? t("modelSelector.retrying") : t("modelSelector.retry")}
</Button>
) : null}
</View>
@@ -461,6 +473,7 @@ function SelectorContent({
isRetryingProvider,
}: SelectorContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
const selectedViewProvider = useMemo(
() =>
@@ -484,7 +497,7 @@ function SelectorContent({
const emptyState = (
<View style={styles.emptyState}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.emptyStateText}>No models match your search</Text>
<Text style={styles.emptyStateText}>{t("modelSelector.noMatches")}</Text>
</View>
);
@@ -501,7 +514,7 @@ function SelectorContent({
color={theme.colors.foregroundMuted}
style={styles.rowSpinner}
/>
<Text style={styles.emptyStateText}>Loading</Text>
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
</View>
);
}
@@ -569,6 +582,7 @@ export function CombinedModelSelector({
serverId = null,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
@@ -653,12 +667,15 @@ export function CombinedModelSelector({
}, [providers, view]);
const triggerLabel = useMemo(() => {
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
if (
selectedModelLabel === t("modelSelector.loading") ||
selectedModelLabel === t("modelSelector.selectModel")
) {
return selectedModelLabel;
}
return buildSelectedTriggerLabel(selectedModelLabel);
}, [selectedModelLabel]);
}, [selectedModelLabel, t]);
useEffect(() => {
if (platformIsWeb) {
@@ -713,7 +730,7 @@ export function CombinedModelSelector({
const sheetHeader = useMemo<SheetHeader>(() => {
if (view.kind === "all") {
return { title: "Select provider" };
return { title: t("modelSelector.title") };
}
const ProviderIconForView = getProviderIcon(view.providerId);
const headerActions = (
@@ -723,7 +740,9 @@ export function CombinedModelSelector({
hitSlop={8}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={`Open ${view.providerLabel} settings`}
accessibilityLabel={t("modelSelector.openProviderSettings", {
provider: view.providerLabel,
})}
testID={`selector-header-settings-${view.providerId}`}
>
<Settings
@@ -742,7 +761,7 @@ export function CombinedModelSelector({
search: {
onChange: handleSearchQueryChange,
resetKey: `${view.providerId}:${searchResetKey}`,
placeholder: "Search models...",
placeholder: t("modelSelector.searchPlaceholder"),
autoFocus: platformIsWeb,
testID: "model-search-input",
},
@@ -757,6 +776,7 @@ export function CombinedModelSelector({
handleBackToAll,
handleSearchQueryChange,
searchResetKey,
t,
theme.iconSize.md,
theme.iconSize.sm,
theme.colors.foreground,
@@ -771,7 +791,7 @@ export function CombinedModelSelector({
onPress={handleTriggerPress}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={`Select model (${selectedModelLabel})`}
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
testID="combined-model-selector"
>
{renderTrigger ? (
@@ -823,7 +843,7 @@ export function CombinedModelSelector({
) : (
<View style={styles.sheetLoadingState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={styles.sheetLoadingText}>Loading model selector</Text>
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
</View>
)}
</Combobox>

View File

@@ -8,6 +8,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
@@ -202,6 +203,7 @@ interface CommandCenterAgentRowContentProps {
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
@@ -222,7 +224,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
</View>
<View style={styles.textContent}>
<Text style={titleStyle} numberOfLines={1}>
{agent.title || "New agent"}
{agent.title || t("shell.commandCenter.newAgent")}
</Text>
<Text style={subtitleStyle} numberOfLines={1}>
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
@@ -256,10 +258,12 @@ function AgentItemsSection({
sectionDividerStyle,
sectionLabelStyle,
}: AgentItemsSectionProps) {
const { t } = useTranslation();
return (
<>
{actionItemsLength > 0 ? <View style={sectionDividerStyle} /> : null}
<Text style={sectionLabelStyle}>Agents</Text>
<Text style={sectionLabelStyle}>{t("shell.commandCenter.agents")}</Text>
{agentItems.map((item, index) => {
const rowIndex = actionItemsLength + index;
const agent = item.agent;
@@ -283,6 +287,7 @@ function AgentItemsSection({
export function CommandCenter() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const {
open,
inputRef,
@@ -442,12 +447,12 @@ export function CommandCenter() {
const resultList =
items.length === 0 ? (
<Text style={emptyTextStyle}>No matches</Text>
<Text style={emptyTextStyle}>{t("shell.commandCenter.noMatches")}</Text>
) : (
<>
{actionItems.length > 0 ? (
<>
<Text style={sectionLabelStyle}>Actions</Text>
<Text style={sectionLabelStyle}>{t("shell.commandCenter.actions")}</Text>
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.action.id}`}
@@ -501,7 +506,7 @@ export function CommandCenter() {
onChangeText={setQuery}
onKeyPress={handleKeyPress}
onSubmitEditing={handleSubmitEditing}
placeholder="Type a command or search agents..."
placeholder={t("shell.commandCenter.placeholder")}
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
@@ -534,7 +539,7 @@ export function CommandCenter() {
ref={inputRef}
value={query}
onChangeText={setQuery}
placeholder="Type a command or search agents..."
placeholder={t("shell.commandCenter.placeholder")}
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
autoCapitalize="none"

View File

@@ -1,6 +1,7 @@
import { Pressable, Text, View } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface ContextWindowMeterProps {
@@ -81,6 +82,7 @@ export function ContextWindowMeter({
showPercentage = false,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const percentage = getUsagePercentage(maxTokens, usedTokens);
if (percentage === null) {
@@ -106,7 +108,9 @@ export function ContextWindowMeter({
<Pressable
style={containerStyle}
accessibilityRole="image"
accessibilityLabel={`Context window ${roundedPercentage}% used`}
accessibilityLabel={t("contextWindow.accessibility", {
percentage: roundedPercentage,
})}
>
<Svg
width={svgSize}
@@ -143,13 +147,20 @@ export function ContextWindowMeter({
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipContent}>
<Text style={styles.tooltipTitle}>Context window</Text>
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
<Text
style={styles.tooltipDetail}
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
<Text style={styles.tooltipTitle}>{t("contextWindow.title")}</Text>
<Text style={styles.tooltipText}>
{t("contextWindow.used", { percentage: roundedPercentage })}
</Text>
<Text style={styles.tooltipDetail}>
{t("contextWindow.tokens", {
used: formatTokenCount(usedTokens),
max: formatTokenCount(maxTokens),
})}
</Text>
{formattedSessionCost ? (
<Text style={styles.tooltipDetail}>{`Session cost ${formattedSessionCost}`}</Text>
<Text style={styles.tooltipDetail}>
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
</Text>
) : null}
</View>
</TooltipContent>

View File

@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { View, Text, Pressable, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { VolumeMeter } from "./volume-meter";
import { FOOTER_HEIGHT } from "@/constants/layout";
import type { DictationStatus } from "@/hooks/use-dictation";
@@ -43,6 +44,7 @@ export function DictationControls({
disabled = false,
}: DictationControlsProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isFailed = status === "failed";
const showActiveState = isRecording || isProcessing || isFailed;
const actionsDisabled = isProcessing;
@@ -71,7 +73,7 @@ export function DictationControls({
onPress={onStart}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel="Start voice dictation"
accessibilityLabel={t("message.dictation.start")}
style={micButtonStyle}
>
<Mic size={theme.iconSize.md} color={theme.colors.foreground} />
@@ -89,7 +91,7 @@ export function DictationControls({
<Pressable
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
accessibilityLabel="Cancel dictation"
accessibilityLabel={t("message.dictation.cancel")}
style={cancelButtonStyle}
>
<X size={theme.iconSize.sm} color={theme.colors.foreground} />
@@ -102,7 +104,7 @@ export function DictationControls({
{!actionsDisabled && isFailed ? (
<Pressable
onPress={onRetry}
accessibilityLabel="Retry dictation"
accessibilityLabel={t("message.dictation.retry")}
style={ACTION_CONFIRM_STYLE}
>
<RefreshCcw size={theme.iconSize.sm} color={theme.colors.surface0} />
@@ -112,14 +114,14 @@ export function DictationControls({
<>
<Pressable
onPress={onAccept}
accessibilityLabel="Insert transcription"
accessibilityLabel={t("message.dictation.insert")}
style={ACTION_SECONDARY_STYLE}
>
<Check size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable>
<Pressable
onPress={onAcceptAndSend}
accessibilityLabel="Insert transcription and send"
accessibilityLabel={t("message.dictation.insertAndSend")}
style={ACTION_CONFIRM_STYLE}
>
<ArrowUp size={theme.iconSize.sm} color={theme.colors.surface0} />
@@ -149,6 +151,7 @@ export function DictationOverlay({
onDiscard,
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & { errorText?: string }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isFailed = status === "failed";
const showActiveState = isRecording || isProcessing || isFailed;
const actionsDisabled = isProcessing;
@@ -189,7 +192,7 @@ export function DictationOverlay({
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
accessibilityRole="button"
accessibilityLabel="Cancel dictation"
accessibilityLabel={t("message.dictation.cancel")}
style={overlayCancelButtonStyle}
>
<X size={theme.iconSize.lg} color={theme.colors.accentForeground} strokeWidth={2.5} />
@@ -208,7 +211,9 @@ export function DictationOverlay({
</View>
{isFailed ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{errorText ? `Dictation failed: ${errorText}` : "Dictation failed. Tap retry."}
{errorText
? t("message.dictation.failed", { error: errorText })
: t("message.dictation.failedRetry")}
</Text>
) : null}
</View>
@@ -223,7 +228,7 @@ export function DictationOverlay({
<Pressable
onPress={onRetry}
accessibilityRole="button"
accessibilityLabel="Retry dictation"
accessibilityLabel={t("message.dictation.retry")}
style={overlayRetryButtonStyle}
>
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
@@ -234,7 +239,7 @@ export function DictationOverlay({
<Pressable
onPress={onAccept}
accessibilityRole="button"
accessibilityLabel="Insert transcription"
accessibilityLabel={t("message.dictation.insert")}
style={OVERLAY_ACCEPT_BUTTON_STYLE}
>
<Pencil
@@ -246,7 +251,7 @@ export function DictationOverlay({
<Pressable
onPress={onAcceptAndSend}
accessibilityRole="button"
accessibilityLabel="Insert transcription and send"
accessibilityLabel={t("message.dictation.insertAndSend")}
style={overlayConfirmButtonStyle}
>
<ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />

View File

@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
@@ -120,10 +121,12 @@ function DiffSegment({
export function DiffViewer({
diffLines,
maxHeight,
emptyLabel = "No changes to display",
emptyLabel,
fillAvailableHeight = false,
}: DiffViewerProps) {
const { t } = useTranslation();
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
const resolvedEmptyLabel = emptyLabel ?? t("diffViewer.empty");
const webScrollbarStyle = useWebScrollbarStyle();
const handleInnerLayout = React.useCallback(
(e: { nativeEvent: { layout: { width: number } } }) =>
@@ -159,7 +162,7 @@ export function DiffViewer({
if (!diffLines.length) {
return (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>{emptyLabel}</Text>
<Text style={styles.emptyText}>{resolvedEmptyLabel}</Text>
</View>
);
}

View File

@@ -1,4 +1,6 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -7,19 +9,20 @@ import { useDownloadStore, formatSpeed, formatEta, type Download } from "@/store
const AUTO_DISMISS_DELAY = 3000;
function getDownloadStatusText(download: Download): string {
function getDownloadStatusText(download: Download, t: TFunction): string {
if (download.status === "downloading") {
if (download.progress) {
return `${Math.round(download.progress.percent * 100)}% · ${formatSpeed(download.progress.speed)} · ${formatEta(download.progress.eta)}`;
}
return "Starting...";
return t("common.states.starting");
}
if (download.status === "complete") return "Download complete";
return download.message ?? "Download failed";
if (download.status === "complete") return t("common.states.downloadComplete");
return download.message ?? t("common.states.downloadFailed");
}
export function DownloadToast() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const downloads = useDownloadStore((state) => state.downloads);
const activeDownloadId = useDownloadStore((state) => state.activeDownloadId);
@@ -78,7 +81,7 @@ export function DownloadToast() {
<Text style={styles.fileName} numberOfLines={1}>
{activeDownload.fileName}
</Text>
<Text style={styles.status}>{getDownloadStatusText(activeDownload)}</Text>
<Text style={styles.status}>{getDownloadStatusText(activeDownload, t)}</Text>
{activeDownload.status === "downloading" && activeDownload.progress && (
<View style={styles.progressBar}>
<ProgressFill percent={activeDownload.progress.percent} />

View File

@@ -12,6 +12,7 @@ import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-nativ
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { GitHubIcon } from "@/components/icons/github-icon";
import { PrPane } from "@/git/pr-pane";
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
@@ -418,6 +419,7 @@ function SidebarContent({
onOpenFile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const padding = useWindowControlsPadding("explorerSidebar");
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
const prPane = usePrPaneData({
@@ -448,7 +450,7 @@ function SidebarContent({
<ExplorerTabButton
tab="changes"
active={resolvedTab === "changes"}
label="Changes"
label={t("workspace.tabs.explorer.changes")}
onTabPress={onTabPress}
testID="explorer-tab-changes"
/>
@@ -456,7 +458,7 @@ function SidebarContent({
<ExplorerTabButton
tab="files"
active={resolvedTab === "files"}
label="Files"
label={t("workspace.tabs.explorer.files")}
onTabPress={onTabPress}
testID="explorer-tab-files"
/>

View File

@@ -3,6 +3,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect, useMemo } from "react";
import { Upload } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "@/composer/types";
import { isWeb } from "@/constants/platform";
@@ -16,6 +17,7 @@ interface FileDropZoneProps {
const IS_WEB = isWeb;
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const { isDragging, containerRef } = useFileDropZone({
onFilesDropped,
@@ -58,7 +60,7 @@ export function FileDropZone({ children, onFilesDropped, disabled = false }: Fil
{/* Content */}
<View style={styles.overlayContent}>
<Upload size={32} color={theme.colors.primary} />
<Text style={styles.overlayText}>Drop images here</Text>
<Text style={styles.overlayText}>{t("composer.attachments.dropImagesHere")}</Text>
</View>
</Animated.View>
</View>

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObject } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
FlatList,
@@ -45,10 +46,10 @@ import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { isWeb } from "@/constants/platform";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" },
{ value: "modified", label: "Modified" },
{ value: "size", label: "Size" },
const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "name" },
{ value: "modified" },
{ value: "size" },
];
const INDENT_PER_LEVEL = 16;
@@ -115,6 +116,7 @@ function TreeRowItem({
onDownloadEntry,
}: TreeRowItemProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isDirectory = entry.kind === "directory";
const handlePress = useCallback(() => {
@@ -181,7 +183,7 @@ function TreeRowItem({
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
Size
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
@@ -189,7 +191,7 @@ function TreeRowItem({
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
Modified
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
@@ -198,11 +200,11 @@ function TreeRowItem({
</View>
<DropdownMenuSeparator />
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
Copy path
{t("workspace.fileExplorer.context.copyPath")}
</DropdownMenuItem>
{entry.kind === "file" ? (
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
Download
{t("workspace.fileExplorer.context.download")}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
@@ -229,6 +231,7 @@ export function FileExplorerPane({
workspaceRoot,
onOpenFile,
}: FileExplorerPaneProps) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
@@ -395,7 +398,15 @@ export function FileExplorerPane({
void refetchExplorer();
}, [refetchExplorer]);
const currentSortLabel = resolveCurrentSortLabel(sortOption);
const sortLabels = useMemo(
() => ({
name: t("workspace.fileExplorer.sort.name"),
modified: t("workspace.fileExplorer.sort.modified"),
size: t("workspace.fileExplorer.sort.size"),
}),
[t],
);
const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels);
const treeRows = useMemo(
() => resolveTreeRows({ directories, expandedPaths, sortOption }),
@@ -453,7 +464,7 @@ export function FileExplorerPane({
if (!hasWorkspaceScope) {
return (
<View style={styles.centerState}>
<Text style={styles.errorText}>Workspace is unavailable</Text>
<Text style={styles.errorText}>{t("workspace.fileExplorer.states.unavailable")}</Text>
</View>
);
}
@@ -503,6 +514,7 @@ interface FileExplorerPaneContentProps {
function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const {
error,
showInitialLoading,
@@ -529,11 +541,11 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
<View style={styles.errorActions}>
{showBackFromError ? (
<Pressable style={styles.retryButton} onPress={handleBackFromError}>
<Text style={styles.retryButtonText}>Back</Text>
<Text style={styles.retryButtonText}>{t("workspace.fileExplorer.actions.back")}</Text>
</Pressable>
) : null}
<Pressable style={styles.retryButton} onPress={handleRetry}>
<Text style={styles.retryButtonText}>Retry</Text>
<Text style={styles.retryButtonText}>{t("workspace.fileExplorer.actions.retry")}</Text>
</Pressable>
</View>
</View>
@@ -544,7 +556,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading files</Text>
<Text style={styles.loadingText}>{t("workspace.fileExplorer.states.loading")}</Text>
</View>
);
}
@@ -552,7 +564,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
if (treeRows.length === 0) {
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>No files</Text>
<Text style={styles.emptyText}>{t("workspace.fileExplorer.empty.noFiles")}</Text>
</View>
);
}
@@ -570,7 +582,11 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
accessibilityLabel={
isRefreshFetching
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
@@ -704,8 +720,11 @@ function resolveShowInitialLoading({
);
}
function resolveCurrentSortLabel(sortOption: SortOption): string {
return SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
function resolveCurrentSortLabel(
sortOption: SortOption,
labels: Record<SortOption, string>,
): string {
return labels[sortOption] ?? labels.name;
}
function resolveTreeRows({

View File

@@ -17,6 +17,7 @@ import {
type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
@@ -503,6 +504,7 @@ function FilePreviewBody({
imagePreviewUri,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const filePath = location.path;
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
@@ -562,7 +564,7 @@ function FilePreviewBody({
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file</Text>
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
@@ -570,7 +572,7 @@ function FilePreviewBody({
if (!preview) {
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>No preview available</Text>
<Text style={styles.emptyText}>{t("panels.file.noPreview")}</Text>
</View>
);
}
@@ -659,7 +661,7 @@ function FilePreviewBody({
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file</Text>
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
@@ -689,7 +691,7 @@ function FilePreviewBody({
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
@@ -704,6 +706,7 @@ export function FilePane({
workspaceRoot: string;
location: WorkspaceFileLocation;
}) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
@@ -726,7 +729,10 @@ export function FilePane({
enabled: Boolean(client && readTarget),
queryFn: async () => {
if (!client || !readTarget) {
return { file: null as ExplorerFile | null, error: "Host is not connected" };
return {
file: null as ExplorerFile | null,
error: t("workspace.terminal.hostDisconnected"),
};
}
try {
const file = await client.readFile(readTarget.cwd, readTarget.path);
@@ -740,7 +746,7 @@ export function FilePane({
return {
file: null,
imageAttachment: null,
error: error instanceof Error ? error.message : "Failed to load file",
error: error instanceof Error ? error.message : t("panels.file.failedToLoad"),
};
}
},

View File

@@ -1,4 +1,5 @@
import { useCallback, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pressable } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -19,6 +20,7 @@ function goBack(): void {
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const handleBack = useCallback(() => {
if (onBack) {
onBack();
@@ -35,7 +37,7 @@ export function BackHeader({ title, titleAccessory, rightContent, onBack }: Back
onPress={handleBack}
style={styles.backButton}
accessibilityRole="button"
accessibilityLabel="Back"
accessibilityLabel={t("common.actions.back")}
>
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>

View File

@@ -1,4 +1,5 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
@@ -48,6 +49,7 @@ export function SidebarMenuToggle({
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
@@ -68,7 +70,7 @@ export function SidebarMenuToggle({
return (
<HeaderToggleButton
onPress={handlePress}
tooltipLabel="Toggle sidebar"
tooltipLabel={t("shell.menu.toggleSidebar")}
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
@@ -76,7 +78,7 @@ export function SidebarMenuToggle({
style={style}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState}
>
{isMobile ? (

View File

@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
import { MarkdownTextSpan } from "@/components/markdown-text";
import * as Clipboard from "expo-clipboard";
import { Check, Copy } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { HighlightToken } from "@getpaseo/highlight";
import { isNative, isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -153,6 +154,7 @@ interface CopyButtonProps {
const COPIED_RESET_MS = 1500;
const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -189,7 +191,7 @@ const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButt
style={wrapperStyle}
pointerEvents={visible ? "auto" : "none"}
accessibilityRole="button"
accessibilityLabel={copied ? "Copied" : "Copy code"}
accessibilityLabel={copied ? t("message.actions.copied") : t("message.actions.copyCode")}
hitSlop={8}
>
{({ hovered }) => {

View File

@@ -1,5 +1,6 @@
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { i18n } from "@/i18n/i18next";
export const PER_PROVIDER_LIMIT = 15;
export const ALL_FILTER_VALUE = "__all__";
@@ -97,11 +98,15 @@ export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string
if (firstPromptPreview) {
return firstPromptPreview;
}
return "Untitled session";
return i18n.t("importSession.preview.untitledSession");
}
export function getPromptPreview(entry: FetchRecentProviderSessionEntry): string {
return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview";
return (
entry.lastPromptPreview?.trim() ||
entry.firstPromptPreview?.trim() ||
i18n.t("importSession.preview.noPrompt")
);
}
export interface EmptyStateInputs {
@@ -132,10 +137,16 @@ export function computeEmptyState(input: EmptyStateInputs): {
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
if (isFilteredEmpty) {
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` };
return {
showEmptyState,
emptyStateTitle: i18n.t("importSession.empty.noProviderSessions", { provider: label }),
};
}
if (input.totalAlreadyImportedCount > 0) {
return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." };
return {
showEmptyState,
emptyStateTitle: i18n.t("importSession.empty.alreadyImported"),
};
}
return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
return { showEmptyState, emptyStateTitle: i18n.t("importSession.empty.noRecent") };
}

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type {
DaemonClient,
FetchRecentProviderSessionEntry,
@@ -14,6 +15,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
import { getProviderIcon } from "@/components/provider-icons";
import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { i18n } from "@/i18n/i18next";
import {
aggregateSessionEntries,
ALL_FILTER_VALUE,
@@ -63,8 +65,10 @@ function buildSessionsQueriesConfig(args: {
visible: boolean;
client: RecentProviderSessionsClient | null;
cwd: string | null | undefined;
hostDisconnectedMessage?: string;
}): SessionsQueryConfig[] {
const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args;
const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } =
args;
if (providersToFetch === null) return [];
const enabled = visible && Boolean(client);
return providersToFetch.map((provider) => ({
@@ -72,7 +76,7 @@ function buildSessionsQueriesConfig(args: {
enabled,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
}
return await client.fetchRecentProviderSessions({
...(cwd ? { cwd } : {}),
@@ -105,33 +109,36 @@ function SheetStatusMessages({
importErrored,
}: SheetStatusMessagesProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
if (!isClientReady) {
return <Text style={styles.statusText}>Connect to a host to import sessions</Text>;
return <Text style={styles.statusText}>{t("importSession.status.connectHost")}</Text>;
}
if (isSnapshotUnsupported) {
return <Text style={styles.statusText}>Update the host to import sessions.</Text>;
return <Text style={styles.statusText}>{t("importSession.status.updateHost")}</Text>;
}
return (
<>
{hasNoImportableProviders ? (
<Text style={styles.statusText}>No importable providers are enabled.</Text>
<Text style={styles.statusText}>{t("importSession.status.noProviders")}</Text>
) : null}
{isLoadingSessions && !hasRows ? (
<View style={styles.statusRow}>
<LoadingSpinner color={theme.colors.foregroundMuted} />
<Text style={styles.statusText}>Loading recent sessions...</Text>
<Text style={styles.statusText}>{t("importSession.status.loading")}</Text>
</View>
) : null}
{allQueriesErrored ? (
<Text style={styles.statusText}>Could not load recent sessions.</Text>
<Text style={styles.statusText}>{t("importSession.status.failedAll")}</Text>
) : null}
{!allQueriesErrored && erroredProviderLabels.length > 0 ? (
<Text style={styles.statusText}>
Could not load sessions for {erroredProviderLabels.join(", ")}.
{t("importSession.status.failedProviders", {
providers: erroredProviderLabels.join(", "),
})}
</Text>
) : null}
{importErrored ? (
<Text style={styles.statusText}>Could not import selected session.</Text>
<Text style={styles.statusText}>{t("importSession.status.failedImport")}</Text>
) : null}
</>
);
@@ -139,6 +146,7 @@ function SheetStatusMessages({
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.refreshButton,
@@ -150,7 +158,7 @@ function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPre
<Pressable
onPress={onPress}
disabled={isRefreshing}
accessibilityLabel="Refresh sessions"
accessibilityLabel={t("importSession.actions.refresh")}
accessibilityRole="button"
testID="import-session-refresh"
style={pressableStyle}
@@ -192,6 +200,7 @@ function ImportSessionSheetRow({
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const title = getSessionTitle(entry);
const promptPreview = getPromptPreview(entry);
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
@@ -229,7 +238,9 @@ function ImportSessionSheetRow({
<Text style={styles.rowTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.rowMeta}>{importing ? "Importing..." : lastActivity}</Text>
<Text style={styles.rowMeta}>
{importing ? t("importSession.row.importing") : lastActivity}
</Text>
</View>
<Text style={styles.rowPreview} numberOfLines={2}>
{promptPreview}
@@ -253,6 +264,7 @@ export function ImportSessionSheet({
onImportedAgent,
onImported,
}: ImportSessionSheetProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { theme } = useUnistyles();
@@ -284,8 +296,9 @@ export function ImportSessionSheet({
visible,
client,
cwd,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
[providersToFetch, sessionsQueryRoot, visible, client, cwd],
[providersToFetch, sessionsQueryRoot, visible, client, cwd, t],
);
const queries = useQueries({ queries: queriesConfig });
@@ -318,19 +331,20 @@ export function ImportSessionSheet({
const filterComboboxOptions = useMemo<ComboboxOption[]>(
() => [
{ id: ALL_FILTER_VALUE, label: "All providers" },
{ id: ALL_FILTER_VALUE, label: t("importSession.filters.all") },
...filterProviders.map((provider) => ({
id: provider,
label: providerLabelById.get(provider) ?? provider,
})),
],
[filterProviders, providerLabelById],
[filterProviders, providerLabelById, t],
);
const selectedProviderLabel = useMemo(
() =>
filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ?? "All providers",
[filterComboboxOptions, selectedProvider],
filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ??
t("importSession.filters.all"),
[filterComboboxOptions, selectedProvider, t],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
@@ -385,7 +399,7 @@ export function ImportSessionSheet({
const importMutation = useMutation({
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
if (!client) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
if (!entry.cwd) {
throw new Error("Session is missing a working directory");
@@ -430,10 +444,10 @@ export function ImportSessionSheet({
const header = useMemo<SheetHeader>(
() => ({
title: "Import session",
title: t("importSession.title"),
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
}),
[isRefreshing, handleRefresh],
[isRefreshing, handleRefresh, t],
);
const isSnapshotUnsupported = !supportsSnapshot;

View File

@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout";
@@ -9,9 +10,9 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
const SNAP_POINTS: string[] = ["70%", "92%"];
const SHORTCUTS_HEADER: SheetHeader = { title: "Shortcuts" };
export function KeyboardShortcutsDialog() {
const { t } = useTranslation();
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
@@ -23,10 +24,11 @@ export function KeyboardShortcutsDialog() {
);
const handleClose = useCallback(() => setOpen(false), [setOpen]);
const header = useMemo<SheetHeader>(() => ({ title: t("settings.shortcuts.dialogTitle") }), [t]);
return (
<AdaptiveModalSheet
header={SHORTCUTS_HEADER}
header={header}
visible={open}
onClose={handleClose}
testID="keyboard-shortcuts-dialog"
@@ -35,13 +37,15 @@ export function KeyboardShortcutsDialog() {
<View testID="keyboard-shortcuts-dialog-content" style={styles.content}>
{sections.map((section) => (
<View key={section.title} style={styles.section}>
<Text style={styles.sectionTitle}>{section.title}</Text>
<Text style={styles.sectionTitle}>{t(section.titleKey)}</Text>
<View style={styles.rows}>
{section.rows.map((row) => (
<View key={row.id} style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowLabel}>{row.label}</Text>
{row.note ? <Text style={styles.rowNote}>{row.note}</Text> : null}
<Text style={styles.rowLabel}>{t(row.labelKey)}</Text>
{row.note ? (
<Text style={styles.rowNote}>{row.noteKey ? t(row.noteKey) : row.note}</Text>
) : null}
</View>
<Shortcut keys={row.keys} style={styles.rowShortcut} />
</View>

View File

@@ -1,5 +1,6 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, Home, MessagesSquare, Plus, Search, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import {
type Dispatch,
memo,
@@ -104,6 +105,7 @@ interface SidebarSharedProps {
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
labels: SidebarLabels;
renderHostOption: (input: {
option: ComboboxOption;
selected: boolean;
@@ -112,6 +114,16 @@ interface SidebarSharedProps {
}) => ReactElement;
}
interface SidebarLabels {
addProject: string;
home: string;
settings: string;
switchHost: string;
searchHosts: string;
sessions: string;
closeSidebar: string;
}
interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number;
insetsBottom: number;
@@ -132,6 +144,7 @@ export const LeftSidebar = memo(function LeftSidebar({
void _selectedAgentId;
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const isCompactLayout = useIsCompactFormFactor();
const isOpen = usePanelStore((state) =>
@@ -146,10 +159,10 @@ export const LeftSidebar = memo(function LeftSidebar({
);
const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => {
if (!activeDaemon) return "No host";
if (!activeDaemon) return t("sidebar.host.noHost");
const trimmed = activeDaemon.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
}, [activeDaemon]);
}, [activeDaemon, t]);
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
const activeHostStatus = activeServerId
? (activeHostSnapshot?.connectionStatus ?? "connecting")
@@ -271,6 +284,19 @@ export const LeftSidebar = memo(function LeftSidebar({
[pathname],
);
const labels = useMemo(
(): SidebarLabels => ({
addProject: t("sidebar.actions.addProject"),
home: t("sidebar.actions.home"),
settings: t("sidebar.actions.settings"),
switchHost: t("sidebar.host.switchTitle"),
searchHosts: t("sidebar.host.searchPlaceholder"),
sessions: t("sidebar.sections.sessions"),
closeSidebar: t("sidebar.actions.closeSidebar"),
}),
[t],
);
const sharedProps = {
theme,
activeServerId,
@@ -291,6 +317,7 @@ export const LeftSidebar = memo(function LeftSidebar({
handleRefresh,
handleHostSelect,
renderHostOption,
labels,
};
if (isCompactLayout) {
@@ -425,12 +452,14 @@ function FooterIconButton({
function AddProjectTooltipContent({
newAgentKeys,
label,
}: {
newAgentKeys: ReturnType<typeof useShortcutKeys>;
label: string;
}) {
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Text style={styles.tooltipText}>{label}</Text>
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
</View>
);
@@ -465,6 +494,7 @@ function SidebarFooter({
handleOpenProject,
handleHome,
handleSettings,
labels,
}: {
theme: SidebarTheme;
activeServerId: string | null;
@@ -479,6 +509,13 @@ function SidebarFooter({
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
labels: {
addProject: string;
home: string;
settings: string;
switchHost: string;
searchHosts: string;
};
}) {
const newAgentKeys = useShortcutKeys("new-agent");
return (
@@ -498,26 +535,26 @@ function SidebarFooter({
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
accessibilityLabel="Add project"
accessibilityLabel={labels.addProject}
icon={FolderPlus}
theme={theme}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<AddProjectTooltipContent newAgentKeys={newAgentKeys} />
<AddProjectTooltipContent newAgentKeys={newAgentKeys} label={labels.addProject} />
</TooltipContent>
</Tooltip>
<FooterIconButton
onPress={handleHome}
testID="sidebar-home"
accessibilityLabel="Home"
accessibilityLabel={labels.home}
icon={Home}
theme={theme}
/>
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
accessibilityLabel="Settings"
accessibilityLabel={labels.settings}
icon={Settings}
theme={theme}
/>
@@ -528,8 +565,8 @@ function SidebarFooter({
onSelect={handleHostSelect}
renderOption={renderHostOption}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
title={labels.switchHost}
searchPlaceholder={labels.searchHosts}
desktopMinWidth={280}
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
@@ -563,6 +600,7 @@ function MobileSidebar({
handleOpenProject,
handleHome,
handleSettings,
labels,
insetsTop,
insetsBottom,
isOpen,
@@ -748,7 +786,7 @@ function MobileSidebar({
<View style={styles.sidebarHeaderRow}>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
@@ -765,7 +803,7 @@ function MobileSidebar({
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel="Close sidebar"
accessibilityLabel={labels.closeSidebar}
hitSlop={8}
>
{({ hovered, pressed }) => (
@@ -810,6 +848,7 @@ function MobileSidebar({
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
/>
</View>
</Animated.View>
@@ -842,6 +881,7 @@ function DesktopSidebar({
handleOpenProject,
handleHome,
handleSettings,
labels,
insetsTop,
isOpen,
handleViewMore,
@@ -919,7 +959,7 @@ function DesktopSidebar({
<View style={styles.sidebarHeaderRow}>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
@@ -963,6 +1003,7 @@ function DesktopSidebar({
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
/>
{/* Resize handle - absolutely positioned over right border */}

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import { getCompactionMarkerLabel } from "./message-compaction-label";
describe("getCompactionMarkerLabel", () => {
@@ -16,4 +17,13 @@ describe("getCompactionMarkerLabel", () => {
);
expect(getCompactionMarkerLabel({ status: "completed" })).toBe("Context compacted");
});
it("renders labels in the active app language", async () => {
await i18n.changeLanguage("zh-CN");
try {
expect(getCompactionMarkerLabel({ status: "loading" })).toBe("正在压缩...");
} finally {
await i18n.changeLanguage("en");
}
});
});

View File

@@ -1,3 +1,5 @@
import { i18n } from "@/i18n/i18next";
export interface CompactionMarkerLabelInput {
status: "loading" | "completed";
trigger?: "auto" | "manual";
@@ -9,9 +11,13 @@ export function getCompactionMarkerLabel({
trigger,
preTokens,
}: CompactionMarkerLabelInput): string {
if (status === "loading") return "Compacting...";
if (trigger === "auto") return "Context automatically compacted";
if (trigger === "manual") return "Context manually compacted";
if (preTokens) return `Context compacted (${Math.round(preTokens / 1000)}K tokens)`;
return "Context compacted";
if (status === "loading") return i18n.t("message.compaction.loading");
if (trigger === "auto") return i18n.t("message.compaction.auto");
if (trigger === "manual") return i18n.t("message.compaction.manual");
if (preTokens) {
return i18n.t("message.compaction.withTokens", {
tokens: Math.round(preTokens / 1000),
});
}
return i18n.t("message.compaction.completed");
}

View File

@@ -10,6 +10,7 @@ import {
ViewStyle,
type TextStyle,
} from "react-native";
import { useTranslation } from "react-i18next";
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import * as React from "react";
@@ -448,18 +449,23 @@ function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAtta
return <Image source={imageSource} style={userMessageStylesheet.imageThumbnail} />;
}
function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
function getUserMessageAttachmentLabel(
attachment: AgentAttachment,
t: ReturnType<typeof useTranslation>["t"],
): string {
switch (attachment.type) {
case "review": {
const count = attachment.comments.length;
return count === 1 ? "Review · 1 comment" : `Review · ${count} comments`;
return count === 1
? t("message.attachments.reviewOne")
: t("message.attachments.reviewMany", { count });
}
case "github_pr":
return `PR #${attachment.number}`;
case "github_issue":
return `Issue #${attachment.number}`;
case "text":
return attachment.title ?? "Text attachment";
return attachment.title ?? t("message.attachments.textAttachment");
default:
return "";
}
@@ -480,6 +486,7 @@ export const UserMessage = memo(function UserMessage({
disableOuterSpacing,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
@@ -562,7 +569,7 @@ export const UserMessage = memo(function UserMessage({
style={userMessageStylesheet.structuredAttachmentPill}
>
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}>
{getUserMessageAttachmentLabel(attachment)}
{getUserMessageAttachmentLabel(attachment, t)}
</Text>
</View>
))}
@@ -588,7 +595,7 @@ export const UserMessage = memo(function UserMessage({
<TurnCopyButton
getContent={getMessageContent}
containerStyle={userMessageStylesheet.copyButton}
accessibilityLabel="Copy message"
accessibilityLabel={t("message.actions.copyMessage")}
/>
</View>
) : null}
@@ -862,6 +869,7 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
const handleImageError = useCallback(() => {
setLoadState({ status: "error" });
}, []);
const { t } = useTranslation();
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
() => [
assistantMessageStylesheet.imageSurface,
@@ -887,7 +895,9 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
<View style={stateSurfaceStyle}>
{loadState.status === "loading" ? <ActivityIndicator size="small" /> : null}
{loadState.status === "error" ? (
<Text style={assistantMessageStylesheet.imageErrorText}>Image unavailable</Text>
<Text style={assistantMessageStylesheet.imageErrorText}>
{t("message.attachments.imageUnavailable")}
</Text>
) : null}
</View>
</View>
@@ -924,6 +934,7 @@ function AssistantMarkdownImage({
workspaceRoot?: string;
serverId?: string;
}) {
const { t } = useTranslation();
const resolution = useMemo(
() => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot],
@@ -953,7 +964,7 @@ function AssistantMarkdownImage({
const file = await client.readFile(resolution.cwd, resolution.path);
if (file.kind !== "image") {
throw new Error("Image preview unavailable.");
throw new Error(t("message.attachments.imagePreviewUnavailable"));
}
return await persistAttachmentFromBytes({
@@ -1026,7 +1037,11 @@ function AssistantMarkdownImage({
);
}
const errorText = resolveAssistantImageErrorText(query.error, dataImageQuery.error);
const errorText = resolveAssistantImageErrorText(
query.error,
dataImageQuery.error,
t("message.attachments.imagePreviewLoadFailed"),
);
return (
<View style={stateFrameStyle}>
@@ -1035,10 +1050,14 @@ function AssistantMarkdownImage({
);
}
function resolveAssistantImageErrorText(fileError: unknown, dataError: unknown): string {
function resolveAssistantImageErrorText(
fileError: unknown,
dataError: unknown,
fallbackText: string,
): string {
if (fileError instanceof Error) return fileError.message;
if (dataError instanceof Error) return dataError.message;
return "Unable to load image preview.";
return fallbackText;
}
function getInlineCodeAutoLinkUrl(
@@ -1144,6 +1163,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
accessibilityLabel,
copiedAccessibilityLabel,
}: TurnCopyButtonProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -1185,7 +1205,9 @@ export const TurnCopyButton = memo(function TurnCopyButton({
style={pressableStyle}
accessibilityRole="button"
accessibilityLabel={
copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn")
copied
? (copiedAccessibilityLabel ?? t("message.actions.copied"))
: (accessibilityLabel ?? t("message.actions.copyTurn"))
}
>
{({ hovered }) => {
@@ -1966,6 +1988,7 @@ export const SpeakMessage = memo(function SpeakMessage({
timestamp: _timestamp,
disableOuterSpacing,
}: SpeakMessageProps) {
const { t } = useTranslation();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const containerStyle = useMemo(
() => [
@@ -1979,7 +2002,7 @@ export const SpeakMessage = memo(function SpeakMessage({
<View testID="speak-message" style={containerStyle}>
<View style={speakMessageStylesheet.header}>
<ThemedMicVocal size={12} uniProps={foregroundMutedColorMapping} />
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
<Text style={speakMessageStylesheet.headerLabel}>{t("message.speak.header")}</Text>
</View>
<Text style={speakMessageStylesheet.text}>{message}</Text>
</View>
@@ -2080,6 +2103,7 @@ export const ActivityLog = memo(function ActivityLog({
onArtifactClick,
disableOuterSpacing,
}: ActivityLogProps) {
const { t } = useTranslation();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isExpanded, setIsExpanded] = useState(false);
@@ -2149,7 +2173,9 @@ export const ActivityLog = memo(function ActivityLog({
</Text>
{metadata && (
<View style={activityLogStylesheet.detailsRow}>
<Text style={activityLogStylesheet.detailsText}>Details</Text>
<Text style={activityLogStylesheet.detailsText}>
{t("message.activity.details")}
</Text>
{isExpanded ? (
<ChevronDown size={12} color="#71717a" />
) : (
@@ -2306,6 +2332,7 @@ export const TodoListCard = memo(function TodoListCard({
items,
disableOuterSpacing,
}: TodoListCardProps) {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false);
const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]);
@@ -2319,7 +2346,7 @@ export const TodoListCard = memo(function TodoListCard({
<View style={todoListCardStylesheet.detailsWrapper}>
<View style={todoListCardStylesheet.list}>
{items.length === 0 ? (
<Text style={todoListCardStylesheet.emptyText}>No tasks yet.</Text>
<Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text>
) : (
items.map((item) => (
<TodoListItemRow key={item.text} text={item.text} completed={item.completed} />
@@ -2328,11 +2355,11 @@ export const TodoListCard = memo(function TodoListCard({
</View>
</View>
);
}, [items]);
}, [items, t]);
return (
<ExpandableBadge
label="Tasks"
label={t("message.todo.title")}
secondaryLabel={nextTask}
icon={CheckSquare}
isExpanded={isExpanded}
@@ -2475,6 +2502,7 @@ function ExpandableBadgeLabelRow({
onOpenFileHoverIn,
onOpenFileHoverOut,
}: ExpandableBadgeLabelRowProps) {
const { t } = useTranslation();
return (
<View
style={expandableBadgeStylesheet.labelRow}
@@ -2499,7 +2527,7 @@ function ExpandableBadgeLabelRow({
onHoverIn={onOpenFileHoverIn}
onHoverOut={onOpenFileHoverOut}
accessibilityRole="button"
accessibilityLabel="Open file"
accessibilityLabel={t("message.actions.openFile")}
testID="tool-call-open-file"
style={expandableBadgeStylesheet.openFileButton}
hitSlop={6}
@@ -3169,7 +3197,6 @@ export const ToolCall = memo(function ToolCall({
if (presentation.isPlan && effectiveDetail?.type === "plan") {
return (
<PlanCard
title="Plan"
text={effectiveDetail.text}
testID="timeline-plan-card"
disableOuterSpacing={disableOuterSpacing}

View File

@@ -1,4 +1,5 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -12,7 +13,6 @@ import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adapt
import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const PAIR_LINK_HEADER: SheetHeader = { title: "Paste pairing link" };
const styles = StyleSheet.create((theme) => ({
helper: {
@@ -61,6 +61,7 @@ export interface PairLinkModalProps {
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile = useIsCompactFormFactor();
@@ -98,11 +99,11 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
if (isSaving) return;
const raw = offerUrlRef.current.trim();
if (!raw) {
setErrorMessage("Paste a pairing link (…/#offer=...)");
setErrorMessage(t("pairing.link.errors.required"));
return;
}
if (!raw.includes("#offer=")) {
setErrorMessage("Link must include #offer=...");
setErrorMessage(t("pairing.link.errors.missingOffer"));
return;
}
@@ -111,15 +112,15 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
const idx = raw.indexOf("#offer=");
const encoded = raw.slice(idx + "#offer=".length).trim();
if (!encoded) {
throw new Error("Offer payload is empty");
throw new Error(t("pairing.link.errors.emptyOffer"));
}
const payload = decodeOfferFragmentPayload(encoded);
return ConnectionOfferSchema.parse(payload);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid pairing link";
const message = error instanceof Error ? error.message : t("pairing.link.errors.invalid");
setErrorMessage(message);
if (!isMobile) {
Alert.alert("Pairing failed", message);
Alert.alert(t("pairing.link.alert.failedTitle"), message);
}
return null;
}
@@ -150,15 +151,16 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to pair host";
const message =
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
setErrorMessage(message);
if (!isMobile) {
Alert.alert("Pairing failed", message);
Alert.alert(t("pairing.link.alert.failedTitle"), message);
}
} finally {
setIsSaving(false);
}
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]);
}, [daemons, handleClose, isMobile, isSaving, onSaved, t, upsertDaemonFromOfferUrl]);
const handleChangeOfferUrl = useCallback((next: string) => {
offerUrlRef.current = next;
@@ -168,22 +170,24 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
void handleSave();
}, [handleSave]);
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.link.title") }), [t]);
return (
<AdaptiveModalSheet
header={PAIR_LINK_HEADER}
header={header}
visible={visible}
onClose={handleClose}
testID="pair-link-modal"
>
<Text style={styles.helper}>Paste the pairing link from your server.</Text>
<Text style={styles.helper}>{t("pairing.link.helper")}</Text>
<View style={styles.field}>
<Text style={styles.label}>Pairing link</Text>
<Text style={styles.label}>{t("pairing.link.label")}</Text>
<AdaptiveTextInput
ref={inputRef}
testID="pair-link-input"
nativeID="pair-link-input"
accessibilityLabel="pair-link-input"
accessibilityLabel={t("pairing.link.label")}
onChangeText={handleChangeOfferUrl}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted}
@@ -204,9 +208,9 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
disabled={isSaving}
testID="pair-link-cancel"
accessibilityRole="button"
accessibilityLabel="Cancel"
accessibilityLabel={t("pairing.link.actions.cancel")}
>
Cancel
{t("pairing.link.actions.cancel")}
</Button>
<Button
style={FLEX_ONE_STYLE}
@@ -215,10 +219,10 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
disabled={isSaving}
testID="pair-link-submit"
accessibilityRole="button"
accessibilityLabel="Pair"
accessibilityLabel={t("pairing.link.actions.pair")}
leftIcon={pairIcon}
>
{isSaving ? "Pairing..." : "Pair"}
{isSaving ? t("pairing.link.actions.pairing") : t("pairing.link.actions.pair")}
</Button>
</View>
</AdaptiveModalSheet>

View File

@@ -2,6 +2,7 @@ import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import Markdown, { type ASTNode } from "react-native-markdown-display";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { getMarkdownListMarker } from "@/utils/markdown-list";
@@ -194,7 +195,7 @@ function createPlanMarkdownRules() {
}
export function PlanCard({
title = "Plan",
title,
description,
text,
footer,
@@ -209,8 +210,10 @@ export function PlanCard({
testID?: string;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const markdownStyles = createMarkdownStyles(theme);
const markdownRules = createPlanMarkdownRules();
const resolvedTitle = title ?? t("agentStream.permission.plan");
const containerStyle = useMemo(
() => [
@@ -234,7 +237,7 @@ export function PlanCard({
return (
<View testID={testID} style={containerStyle}>
<Text style={titleStyle}>{title}</Text>
<Text style={titleStyle}>{resolvedTitle}</Text>
{description ? <Text style={descriptionStyle}>{description}</Text> : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{text}

View File

@@ -11,6 +11,7 @@ import {
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
@@ -60,6 +61,7 @@ function PathRow({ path, active, onSelect }: PathRowProps) {
export function ProjectPickerModal() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const serverId = useActiveServerId();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
@@ -235,7 +237,7 @@ export function ProjectPickerModal() {
ref={inputRef}
value={query}
onChangeText={handleChangeQuery}
placeholder="Type a directory path..."
placeholder={t("projectPicker.placeholder")}
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
autoCapitalize="none"
@@ -253,9 +255,9 @@ export function ProjectPickerModal() {
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{isSubmitting ? <Text style={emptyTextStyle}>Opening project...</Text> : null}
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
{!isSubmitting && options.length === 0 && !query.trim() ? (
<Text style={emptyTextStyle}>Start typing a path</Text>
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
) : null}
{!isSubmitting && !(options.length === 0 && !query.trim()) ? (
<>

View File

@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { SvgXml } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -48,6 +49,10 @@ interface CatalogRowProps {
}
function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
const { t } = useTranslation();
const actionLabel = installing
? t("providerCatalog.actions.adding")
: t("providerCatalog.actions.add");
const handleInstall = useCallback(() => {
onInstall(entry);
}, [entry, onInstall]);
@@ -84,12 +89,14 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
</Text>
<Pressable
accessibilityRole="link"
accessibilityLabel={`${entry.title} install instructions`}
accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
provider: entry.title,
})}
onPress={handleOpenInstallLink}
style={styles.installLink}
>
<Text style={styles.installLinkText} numberOfLines={1}>
Install instructions
{t("providerCatalog.actions.installInstructions")}
</Text>
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
</Pressable>
@@ -103,7 +110,7 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
style={styles.actionButton}
testID={`install-provider-${entry.id}`}
>
{installing ? "Adding" : "Add"}
{actionLabel}
</Button>
</View>
);
@@ -114,6 +121,7 @@ export function ProviderCatalogList({
installingProviderId,
onInstall,
}: ProviderCatalogListProps) {
const { t } = useTranslation();
const { entries: catalogEntries } = useAcpProviderCatalog();
const { entries: providerEntries } = useProvidersSnapshot(serverId);
const [search, setSearch] = useState("");
@@ -139,10 +147,10 @@ export function ProviderCatalogList({
</View>
<AdaptiveTextInput
testID="provider-catalog-search"
accessibilityLabel="Search providers"
value={search}
onChangeText={setSearch}
placeholder="Search providers"
accessibilityLabel={t("providerCatalog.search")}
placeholder={t("providerCatalog.search")}
style={styles.searchInput}
autoCapitalize="none"
autoCorrect={false}
@@ -151,9 +159,7 @@ export function ProviderCatalogList({
{availableEntries.length === 0 ? (
<View style={styles.stateBox}>
<Text style={styles.stateText}>
{search.trim().length > 0 ? "No providers found" : "All providers are installed"}
</Text>
<Text style={styles.stateText}>{t("providerCatalog.noProviders")}</Text>
</View>
) : (
<View style={styles.list}>

View File

@@ -1,5 +1,7 @@
import { AlertTriangle, FileText, Plus, 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 {
ActivityIndicator,
Pressable,
@@ -79,6 +81,7 @@ function CustomModelRow({
deleting: boolean;
onDelete: (modelId: string) => void;
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]);
const deleteButtonStyle = useCallback(
@@ -110,7 +113,7 @@ function CustomModelRow({
hitSlop={8}
style={deleteButtonStyle}
accessibilityRole="button"
accessibilityLabel={`Remove ${model.id}`}
accessibilityLabel={t("settings.providers.models.removeModel", { id: model.id })}
>
<Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />
</Pressable>
@@ -148,6 +151,7 @@ function AddCustomModelSubSheet({
onClose: () => void;
refresh: (providers?: AgentProvider[]) => Promise<void>;
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const { config, patchConfig } = useDaemonConfig(serverId);
const [input, setInput] = useState("");
@@ -182,12 +186,15 @@ function AddCustomModelSubSheet({
.then(() => refresh([provider]))
.then(() => onClose())
.catch((err) => {
setError(err instanceof Error ? err.message : "Failed to save model");
setError(err instanceof Error ? err.message : t("settings.providers.models.failedToSave"));
})
.finally(() => setSaving(false));
}, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, trimmed]);
}, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, t, trimmed]);
const header = useMemo<SheetHeader>(() => ({ title: "Add custom model" }), []);
const header = useMemo<SheetHeader>(
() => ({ title: t("settings.providers.models.addCustomTitle") }),
[t],
);
return (
<AdaptiveModalSheet
@@ -199,14 +206,14 @@ function AddCustomModelSubSheet({
testID="add-custom-model-sheet"
>
<View style={sheetStyles.formGroup}>
<Text style={sheetStyles.formLabel}>Model ID</Text>
<Text style={sheetStyles.formLabel}>{t("settings.providers.models.modelId")}</Text>
<AdaptiveTextInput
initialValue={input}
resetKey={`add-custom-${visible}`}
value={input}
onChangeText={setInput}
onSubmitEditing={handleAdd}
placeholder="e.g. openai/gpt-5"
placeholder={t("settings.providers.models.modelIdPlaceholder")}
placeholderTextColor={theme.colors.foregroundMuted}
autoCapitalize="none"
autoCorrect={false}
@@ -217,10 +224,10 @@ function AddCustomModelSubSheet({
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
<View style={sheetStyles.formActions}>
<Button variant="secondary" size="sm" onPress={onClose} disabled={saving}>
Cancel
{t("common.actions.cancel")}
</Button>
<Button variant="default" size="sm" onPress={handleAdd} disabled={!canAdd || saving}>
{saving ? "Adding" : "Add"}
{saving ? t("settings.providers.models.adding") : t("settings.providers.models.add")}
</Button>
</View>
</View>
@@ -239,6 +246,7 @@ function DiagnosticSubSheet({
visible: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const client = useHostRuntimeClient(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
@@ -251,11 +259,13 @@ function DiagnosticSubSheet({
const result = await client.getProviderDiagnostic(provider);
setDiagnostic(result.diagnostic);
} catch (err) {
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
setDiagnostic(
err instanceof Error ? err.message : t("settings.providers.diagnostic.failedToFetch"),
);
} finally {
setLoading(false);
}
}, [client, provider]);
}, [client, provider, t]);
useEffect(() => {
if (visible) {
@@ -280,7 +290,7 @@ function DiagnosticSubSheet({
const header = useMemo<SheetHeader>(
() => ({
title: "Diagnostic",
title: t("settings.providers.diagnostic.title"),
actions: (
<Pressable
onPress={handleRefreshPress}
@@ -288,7 +298,11 @@ function DiagnosticSubSheet({
hitSlop={8}
style={refreshButtonStyle}
accessibilityRole="button"
accessibilityLabel={loading ? "Refreshing diagnostic" : "Refresh diagnostic"}
accessibilityLabel={
loading
? t("settings.providers.diagnostic.refreshingAccessibility")
: t("settings.providers.diagnostic.refreshAccessibility")
}
>
{loading ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
@@ -302,6 +316,7 @@ function DiagnosticSubSheet({
handleRefreshPress,
loading,
refreshButtonStyle,
t,
theme.colors.foregroundMuted,
theme.iconSize.sm,
],
@@ -312,7 +327,7 @@ function DiagnosticSubSheet({
body = (
<View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Running diagnostic</Text>
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
</View>
);
} else if (diagnostic) {
@@ -328,7 +343,7 @@ function DiagnosticSubSheet({
} else {
body = (
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>No diagnostic available</Text>
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
</View>
);
}
@@ -366,6 +381,7 @@ interface ProviderSheetFooterInput {
fetchedAtLabel: string | null;
isCompact: boolean;
modelsRefreshing: boolean;
t: TFunction;
onOpenAddSheet: () => void;
onOpenDiagSheet: () => void;
onRefreshModels: () => void;
@@ -375,6 +391,7 @@ function renderProviderSheetFooter({
fetchedAtLabel,
isCompact,
modelsRefreshing,
t,
onOpenAddSheet,
onOpenDiagSheet,
onRefreshModels,
@@ -388,7 +405,7 @@ function renderProviderSheetFooter({
<View style={contentStyle}>
{fetchedAtLabel || !isCompact ? (
<Text style={metaStyle} numberOfLines={1}>
{fetchedAtLabel ? `Updated ${fetchedAtLabel}` : ""}
{fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""}
</Text>
) : null}
<View style={actionsStyle}>
@@ -399,7 +416,7 @@ function renderProviderSheetFooter({
onPress={onOpenAddSheet}
style={buttonStyle}
>
Add model
{t("settings.providers.models.addModel")}
</Button>
<Button
variant="secondary"
@@ -408,7 +425,7 @@ function renderProviderSheetFooter({
onPress={onOpenDiagSheet}
style={buttonStyle}
>
Diagnostic
{t("settings.providers.diagnostic.button")}
</Button>
<Button
variant="default"
@@ -418,7 +435,9 @@ function renderProviderSheetFooter({
disabled={modelsRefreshing}
style={buttonStyle}
>
{modelsRefreshing ? "Refreshing…" : "Refresh"}
{modelsRefreshing
? t("settings.providers.diagnostic.refreshing")
: t("settings.providers.diagnostic.refresh")}
</Button>
</View>
</View>
@@ -426,6 +445,7 @@ function renderProviderSheetFooter({
}
function ProviderModalBody(props: ProviderModalBodyProps) {
const { t } = useTranslation();
const {
discoveredCount,
additionalCount,
@@ -445,7 +465,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
return (
<View style={sheetStyles.emptyState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Loading models</Text>
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.loading")}</Text>
</View>
);
}
@@ -455,7 +475,9 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
<Button variant="default" size="sm" onPress={onRefresh} disabled={modelsRefreshing}>
{modelsRefreshing ? "Retrying…" : "Retry"}
{modelsRefreshing
? t("settings.providers.models.retrying")
: t("settings.providers.models.retry")}
</Button>
</View>
);
@@ -463,14 +485,14 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
if (filteredDiscovered.length === 0 && filteredCustom.length === 0 && searchActive) {
return (
<View style={sheetStyles.emptyState}>
<Text style={sheetStyles.mutedText}>No models match your search</Text>
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noSearchMatches")}</Text>
</View>
);
}
if (discoveredCount === 0 && additionalCount === 0) {
return (
<View style={sheetStyles.emptyState}>
<Text style={sheetStyles.mutedText}>No models detected</Text>
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noneDetected")}</Text>
</View>
);
}
@@ -478,7 +500,10 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
<>
{filteredDiscovered.length > 0 ? (
<View style={sheetStyles.section}>
<SectionHeader title="Discovered" count={filteredDiscovered.length} />
<SectionHeader
title={t("settings.providers.models.discovered")}
count={filteredDiscovered.length}
/>
<View style={settingsStyles.card}>
{filteredDiscovered.map((model) => (
<DiscoveredModelRow key={model.id} model={model} />
@@ -488,7 +513,10 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
) : null}
{filteredCustom.length > 0 ? (
<View style={sheetStyles.section}>
<SectionHeader title="Custom models" count={filteredCustom.length} />
<SectionHeader
title={t("settings.providers.models.custom")}
count={filteredCustom.length}
/>
<View style={settingsStyles.card}>
{filteredCustom.map((model) => (
<CustomModelRow
@@ -511,6 +539,7 @@ export function ProviderDiagnosticSheet({
onClose,
serverId,
}: ProviderDiagnosticSheetProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
@@ -531,7 +560,9 @@ export function ProviderDiagnosticSheet({
);
const providerSnapshotRefreshing = providerEntry?.status === "loading";
const providerErrorMessage =
providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null;
providerEntry?.status === "error"
? (providerEntry.error ?? t("settings.providers.diagnostic.unknownError"))
: null;
const modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
const stableDiscoveredRef = useRef<AgentModelDefinition[]>([]);
@@ -546,7 +577,7 @@ export function ProviderDiagnosticSheet({
const [clockTick, setClockTick] = useState(0);
useEffect(() => {
if (!visible) return;
const id = setInterval(() => setClockTick((t) => t + 1), 10_000);
const id = setInterval(() => setClockTick((tick) => tick + 1), 10_000);
return () => clearInterval(id);
}, [visible]);
const fetchedAtLabel = useMemo(() => {
@@ -605,11 +636,11 @@ export function ProviderDiagnosticSheet({
title: providerLabel,
search: {
onChange: setQuery,
placeholder: "Search models",
placeholder: t("settings.providers.models.searchPlaceholder"),
testID: "provider-settings-search",
},
}),
[providerLabel],
[providerLabel, t],
);
return (
@@ -623,6 +654,7 @@ export function ProviderDiagnosticSheet({
fetchedAtLabel,
isCompact,
modelsRefreshing,
t,
onOpenAddSheet: handleOpenAddSheet,
onOpenDiagSheet: handleOpenDiagSheet,
onRefreshModels: handleRefreshModels,

View File

@@ -138,6 +138,9 @@ export function shouldSubmitEmptyOnDismiss(questions: QuestionFormQuestion[]): b
);
}
export function resolveDismissLabel(questions: QuestionFormQuestion[]): string {
return questions.find((question) => question.dismissLabel)?.dismissLabel ?? "Dismiss";
export function resolveDismissLabel(
questions: QuestionFormQuestion[],
fallbackLabel = "Dismiss",
): string {
return questions.find((question) => question.dismissLabel)?.dismissLabel ?? fallbackLabel;
}

View File

@@ -10,6 +10,7 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { isWeb } from "@/constants/platform";
@@ -33,9 +34,17 @@ interface QuestionFormCardProps {
const IS_WEB = isWeb;
function getQuestionInputPlaceholder(question: QuestionFormQuestion): string {
function getQuestionInputPlaceholder({
question,
answerPlaceholder,
otherPlaceholder,
}: {
question: QuestionFormQuestion;
answerPlaceholder: string;
otherPlaceholder: string;
}): string {
return (
question.placeholder ?? (question.options.length === 0 ? "Type your answer..." : "Other...")
question.placeholder ?? (question.options.length === 0 ? answerPlaceholder : otherPlaceholder)
);
}
@@ -239,6 +248,7 @@ function QuestionOtherInput({
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const questions = useMemo(
() => parseQuestionFormQuestions(permission.request.input),
@@ -367,7 +377,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
);
const primaryDisabled = isResponding || (isLastQuestion ? !allAnswered : !activeQuestionAnswered);
const primaryActionLabel = isLastQuestion ? "Submit" : "Next";
const primaryActionLabel = isLastQuestion
? t("message.question.submit")
: t("message.question.next");
const submitButtonStyle = useCallback(
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton,
@@ -417,7 +429,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
return null;
}
const dismissLabel = resolveDismissLabel(questions);
const dismissLabel = resolveDismissLabel(questions, t("common.actions.dismiss"));
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
@@ -470,7 +482,11 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
qIndex={resolvedActiveQuestionIndex}
accessibilityLabel={activeQuestion.question}
value={otherText}
placeholder={getQuestionInputPlaceholder(activeQuestion)}
placeholder={getQuestionInputPlaceholder({
question: activeQuestion,
answerPlaceholder: t("message.question.answerPlaceholder"),
otherPlaceholder: t("message.question.otherPlaceholder"),
})}
isResponding={isResponding}
onChange={setOtherText}
onSubmit={handlePrimaryAction}

View File

@@ -1,11 +1,13 @@
import { useEffect, useState } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { getIsElectronRuntime } from "@/constants/layout";
import { listenToDesktopEvent } from "@/desktop/electron/events";
export function QuittingOverlay() {
const { t } = useTranslation();
const { theme } = useUnistyles();
const [quitting, setQuitting] = useState(false);
@@ -37,8 +39,8 @@ export function QuittingOverlay() {
return (
<View style={styles.overlay}>
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
<Text style={styles.title}>Quitting Paseo</Text>
<Text style={styles.detail}>Stopping the local daemon.</Text>
<Text style={styles.title}>{t("desktop.quitting.title")}</Text>
<Text style={styles.detail}>{t("desktop.quitting.detail")}</Text>
</View>
);
}

View File

@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native";
@@ -23,6 +24,7 @@ export function RealtimeVoiceOverlay({
onStop,
}: RealtimeVoiceOverlayProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const { volume, isSpeaking } = useVoiceTelemetry();
const muteButtonStyle = useMemo(
() => [
@@ -53,7 +55,9 @@ export function RealtimeVoiceOverlay({
onPress={onToggleMute}
disabled={isSwitching}
accessibilityRole="button"
accessibilityLabel={isMuted ? "Unmute realtime voice" : "Mute realtime voice"}
accessibilityLabel={
isMuted ? t("realtimeVoice.actions.unmute") : t("realtimeVoice.actions.mute")
}
style={muteButtonStyle}
>
{isMuted ? (
@@ -67,7 +71,7 @@ export function RealtimeVoiceOverlay({
onPress={onStop}
disabled={isSwitching}
accessibilityRole="button"
accessibilityLabel="Stop realtime voice and interrupt turn"
accessibilityLabel={t("realtimeVoice.actions.stop")}
style={stopButtonStyle}
>
{isSwitching ? (

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text, TextInput, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
@@ -27,13 +28,14 @@ export function AdaptiveRenameModal({
title,
initialValue,
placeholder,
submitLabel = "Rename",
submitLabel,
onClose,
onSubmit,
validate,
maxLength,
testID,
}: AdaptiveRenameModalProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
@@ -64,10 +66,10 @@ export function AdaptiveRenameModal({
const computeError = useCallback(
(value: string): string | null => {
if (!value.trim()) return "Name is required";
if (!value.trim()) return t("common.errors.nameRequired");
return validate ? validate(value) : null;
},
[validate],
[validate, t],
);
const handleChange = useCallback((value: string) => {
@@ -91,10 +93,11 @@ export function AdaptiveRenameModal({
onClose();
} catch (err) {
setIsPending(false);
const message = err instanceof Error && err.message ? err.message : "Unable to save";
const message =
err instanceof Error && err.message ? err.message : t("common.errors.unableToSave");
setError(message);
}
}, [isPending, draft, initialValue, computeError, onSubmit, onClose]);
}, [isPending, draft, initialValue, computeError, onSubmit, onClose, t]);
const handleCancel = useCallback(() => {
if (isPending) return;
@@ -147,7 +150,7 @@ export function AdaptiveRenameModal({
disabled={isPending}
testID={cancelTestID}
>
Cancel
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
@@ -157,7 +160,7 @@ export function AdaptiveRenameModal({
disabled={submitDisabled}
testID={submitTestID}
>
{isPending ? "Saving..." : submitLabel}
{isPending ? t("renameModal.saving") : (submitLabel ?? t("renameModal.rename"))}
</Button>
</View>
</View>

View File

@@ -1,4 +1,5 @@
import { memo, useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { FileText, Layers, MessageSquare, Undo2 } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -42,7 +43,16 @@ export const RewindMenu = memo(function RewindMenu({
testID = "rewind-menu",
}: RewindMenuProps) {
const { theme } = useUnistyles();
const items = useRewindCapabilities(capabilities);
const { t } = useTranslation();
const rewindLabels = useMemo(
() => ({
conversation: t("rewind.actions.conversation"),
files: t("rewind.actions.files"),
both: t("rewind.actions.both"),
}),
[t],
);
const items = useRewindCapabilities(capabilities, rewindLabels);
const [isOpen, setIsOpen] = useState(false);
const [pendingMode, setPendingMode] = useState<RewindMode | null>(null);
const isLocked = isPendingProp || pendingMode !== null;
@@ -79,10 +89,10 @@ export const RewindMenu = memo(function RewindMenu({
const tooltipContent = useMemo(
() => (
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Rewind to this message</Text>
<Text style={styles.tooltipText}>{t("rewind.tooltip")}</Text>
</TooltipContent>
),
[],
[t],
);
if (items.length === 0) {
@@ -95,7 +105,7 @@ export const RewindMenu = memo(function RewindMenu({
<TooltipTrigger asChild>
<View style={styles.triggerSlot} collapsable={false}>
<DropdownMenuTrigger
accessibilityLabel="Rewind to this message"
accessibilityLabel={t("rewind.tooltip")}
accessibilityRole="button"
disabled={isLocked}
style={triggerStyle}
@@ -114,7 +124,7 @@ export const RewindMenu = memo(function RewindMenu({
</Tooltip>
<DropdownMenuContent align="end" minWidth={220} side="bottom" testID={`${testID}-content`}>
<View style={styles.warningHeader}>
<Text style={styles.warningText}>This action cannot be undone</Text>
<Text style={styles.warningText}>{t("rewind.warning")}</Text>
</View>
<DropdownMenuSeparator />
{items.map((item) => (

View File

@@ -1,4 +1,5 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { useToast } from "@/contexts/toast-context";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -25,11 +26,12 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
isPending: boolean;
} {
const toast = useToast();
const { t } = useTranslation();
const composerRestore = useRewindComposerRestore();
const { isPending, mutateAsync } = useMutation({
mutationFn: async ({ mode }: RewindAgentInput) => {
if (!input.client || !input.agentId || !input.messageId) {
throw new Error("Daemon client not available");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
await input.client.rewindAgent(input.agentId, input.messageId, mode);
if (mode !== "files") {
@@ -59,7 +61,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
composerRestore?.restoreTextIfComposerEmpty(variables.rewoundText);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to rewind agent");
toast.error(error instanceof Error ? error.message : t("rewind.errors.failed"));
},
});

View File

@@ -33,4 +33,21 @@ describe("resolveRewindMenuItems", () => {
},
]);
});
test("uses caller-provided labels for available capabilities", () => {
expect(
resolveRewindMenuItems(
{
supportsRewindConversation: true,
supportsRewindFiles: true,
supportsRewindBoth: false,
},
{
conversation: "Conversation label",
files: "Files label",
both: "Both label",
},
).map((item) => item.label),
).toEqual(["Conversation label", "Files label"]);
});
});

View File

@@ -9,6 +9,18 @@ export interface RewindMenuItem {
testID: string;
}
export interface RewindMenuLabels {
conversation: string;
files: string;
both: string;
}
const DEFAULT_REWIND_MENU_LABELS: RewindMenuLabels = {
conversation: "Rewind conversation",
files: "Rewind files",
both: "Rewind conversation and files",
};
export function resolveRewindMenuItems(
capabilities:
| Pick<
@@ -17,29 +29,31 @@ export function resolveRewindMenuItems(
>
| null
| undefined,
labelsInput?: Partial<RewindMenuLabels>,
): RewindMenuItem[] {
if (!capabilities) {
return [];
}
const labels = { ...DEFAULT_REWIND_MENU_LABELS, ...labelsInput };
const items: RewindMenuItem[] = [];
if (capabilities.supportsRewindConversation) {
items.push({
mode: "conversation",
label: "Rewind conversation",
label: labels.conversation,
testID: "rewind-menu-conversation",
});
}
if (capabilities.supportsRewindFiles) {
items.push({
mode: "files",
label: "Rewind files",
label: labels.files,
testID: "rewind-menu-files",
});
}
if (capabilities.supportsRewindBoth) {
items.push({
mode: "both",
label: "Rewind conversation and files",
label: labels.both,
testID: "rewind-menu-both",
});
}
@@ -48,6 +62,7 @@ export function resolveRewindMenuItems(
export function useRewindCapabilities(
capabilities: Parameters<typeof resolveRewindMenuItems>[0],
labels?: Partial<RewindMenuLabels>,
): RewindMenuItem[] {
return useMemo(() => resolveRewindMenuItems(capabilities), [capabilities]);
return useMemo(() => resolveRewindMenuItems(capabilities, labels), [capabilities, labels]);
}

View File

@@ -2,6 +2,7 @@ import { X } from "lucide-react-native";
import { useCallback, useMemo, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
export type SidebarCalloutActionVariant = "primary" | "secondary";
@@ -38,6 +39,7 @@ export function SidebarCallout({
onDismiss,
testID,
}: SidebarCalloutProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const visibleActions = (actions ?? []).slice(0, 2);
const hasHeader = title != null || icon != null;
@@ -69,7 +71,7 @@ export function SidebarCallout({
hitSlop={8}
style={styles.dismissButton}
testID={testID ? `${testID}-dismiss` : undefined}
accessibilityLabel="Dismiss"
accessibilityLabel={t("sidebarCallout.dismiss")}
accessibilityRole="button"
>
{({ hovered }) => (

View File

@@ -27,6 +27,7 @@ import {
type MutableRefObject,
type Ref,
} from "react";
import { useTranslation } from "react-i18next";
import { router, usePathname, type Href } from "expo-router";
import {
navigateToWorkspace,
@@ -123,7 +124,10 @@ import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
import {
confirmRiskyWorktreeArchive,
type WorktreeArchiveWarningLabels,
} from "@/git/worktree-archive-warning";
import {
archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically,
@@ -183,6 +187,40 @@ const syncedLoaderColorMapping = (theme: Theme) => ({
: theme.colors.palette.amber[500],
});
function getWorktreeArchiveWarningLabels(
t: (key: string, options?: Record<string, unknown>) => string,
): WorktreeArchiveWarningLabels {
return {
title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }),
confirm: t("workspace.git.actions.archiveWarning.confirm"),
cancel: t("workspace.git.actions.archiveWarning.cancel"),
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
uncommittedChangesWithDiff: (diffStat) =>
t("workspace.git.actions.archiveWarning.uncommittedChangesWithDiff", { diffStat }),
addedLine: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.addedLine"
: "workspace.git.actions.archiveWarning.addedLines",
{ count },
),
deletedLine: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.deletedLine"
: "workspace.git.actions.archiveWarning.deletedLines",
{ count },
),
unpushedCommit: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.unpushedCommit"
: "workspace.git.actions.archiveWarning.unpushedCommits",
{ count },
),
};
}
function getPrIconUniMapping(state: PrHint["state"]) {
switch (state) {
case "merged":
@@ -307,6 +345,7 @@ function getWorkspaceArchiveStatus(
}
export function PrBadge({ hint }: { hint: PrHint }) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const handlePressIn = useCallback((event: GestureResponderEvent) => {
@@ -330,7 +369,9 @@ export function PrBadge({ hint }: { hint: PrHint }) {
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Pull request #${hint.number}`}
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
number: hint.number,
})}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
@@ -572,6 +613,7 @@ function ProjectKebabMenu({
onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success";
}) {
const { t } = useTranslation();
const toast = useToast();
const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return;
@@ -589,16 +631,16 @@ function ProjectKebabMenu({
?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath })
?.catch((error) => {
console.warn("[sidebar] openNew failed", error);
toast.error("Couldn't open a new window");
toast.error(t("sidebar.project.actions.openNewWindowFailed"));
});
}, [projectPath, toast]);
}, [projectPath, t, toast]);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={projectKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Project actions"
accessibilityLabel={t("sidebar.project.actions.menu")}
testID={`sidebar-project-kebab-${projectKey}`}
>
{renderKebabTriggerIcon}
@@ -610,7 +652,7 @@ function ProjectKebabMenu({
leading={settingsLeadingIcon}
onSelect={handleOpenProjectSettings}
>
Open project settings
{t("sidebar.project.actions.openSettings")}
</DropdownMenuItem>
) : null}
{canOpenInNewWindow ? (
@@ -619,17 +661,17 @@ function ProjectKebabMenu({
leading={openInNewWindowLeadingIcon}
onSelect={handleOpenInNewWindow}
>
Open in new window
{t("sidebar.project.actions.openNewWindow")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`}
leading={trash2LeadingIcon}
status={removeProjectStatus}
pendingLabel="Removing..."
pendingLabel={t("sidebar.project.actions.removing")}
onSelect={onRemoveProject}
>
Remove project
{t("sidebar.project.actions.remove")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -669,13 +711,16 @@ function WorkspaceRowRightGroup({
onCopyPath?: () => void;
onRename?: () => void;
}) {
const { t } = useTranslation();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
return (
<>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{isCreating ? (
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
) : null}
{shouldRenderActionSlot ? (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase
@@ -733,6 +778,7 @@ function WorkspaceKebabMenu({
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const { t } = useTranslation();
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
@@ -743,7 +789,7 @@ function WorkspaceKebabMenu({
hitSlop={8}
style={workspaceKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Workspace actions"
accessibilityLabel={t("sidebar.workspace.actions.menu")}
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{renderKebabTriggerIcon}
@@ -755,7 +801,7 @@ function WorkspaceKebabMenu({
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
Copy path
{t("sidebar.workspace.actions.copyPath")}
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
@@ -764,7 +810,7 @@ function WorkspaceKebabMenu({
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
Copy branch name
{t("sidebar.workspace.actions.copyBranchName")}
</DropdownMenuItem>
) : null}
{onRename ? (
@@ -773,7 +819,7 @@ function WorkspaceKebabMenu({
leading={renameLeadingIcon}
onSelect={onRename}
>
Rename workspace
{t("sidebar.workspace.actions.rename")}
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
@@ -793,7 +839,7 @@ function WorkspaceKebabMenu({
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -912,6 +958,7 @@ function NewWorktreeButton({
testID: string;
showShortcutHint?: boolean;
}) {
const { t } = useTranslation();
const newWorktreeKeys = useShortcutKeys("new-worktree");
const pressableStyle = useCallback(
@@ -940,7 +987,9 @@ function NewWorktreeButton({
onPress={handlePress}
disabled={loading}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel={`Create a new workspace for ${displayName}`}
accessibilityLabel={t("sidebar.workspace.actions.createWorkspaceFor", {
projectName: displayName,
})}
testID={testID}
>
{({ hovered, pressed }) =>
@@ -959,7 +1008,9 @@ function NewWorktreeButton({
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.projectActionTooltipRow}>
<Text style={styles.projectActionTooltipText}>New workspace</Text>
<Text style={styles.projectActionTooltipText}>
{t("sidebar.workspace.actions.newWorkspace")}
</Text>
{showShortcutHint && newWorktreeKeys ? (
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
) : null}
@@ -1481,6 +1532,7 @@ function WorkspaceRowWithMenu({
canCopyBranchName: boolean;
isCreating?: boolean;
}) {
const { t } = useTranslation();
const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const queryClient = useQueryClient();
@@ -1513,12 +1565,15 @@ function WorkspaceRowWithMenu({
return;
}
const confirmed = await confirmRiskyWorktreeArchive({
worktreeName: workspace.name,
isDirty: workspace.archiveHasUncommittedChanges,
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
diffStat: workspace.diffStat,
});
const confirmed = await confirmRiskyWorktreeArchive(
{
worktreeName: workspace.name,
isDirty: workspace.archiveHasUncommittedChanges,
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
diffStat: workspace.diffStat,
},
getWorktreeArchiveWarningLabels(t),
);
if (!confirmed) {
return;
@@ -1530,12 +1585,16 @@ function WorkspaceRowWithMenu({
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
if (!archiveDirectory) {
toast.error("Workspace path not available");
toast.error(t("sidebar.workspace.toasts.workspacePathUnavailable"));
return;
}
@@ -1546,10 +1605,11 @@ function WorkspaceRowWithMenu({
cwd: archiveDirectory,
worktreePath: archiveDirectory,
}).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to archive worktree";
const message =
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed");
toast.error(message);
});
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
}, [archiveWorktree, isArchiving, redirectAfterArchive, t, toast, workspace]);
const handleArchiveWorktree = useCallback(() => {
void archiveWorktreeAfterConfirmation();
@@ -1561,10 +1621,10 @@ function WorkspaceRowWithMenu({
}
const confirmed = await confirmDialog({
title: "Hide workspace?",
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Hide",
cancelLabel: "Cancel",
title: t("sidebar.workspace.confirmations.hideTitle"),
message: t("sidebar.workspace.confirmations.hideMessage", { workspaceName: workspace.name }),
confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"),
cancelLabel: t("sidebar.workspace.confirmations.cancel"),
destructive: true,
});
if (!confirmed) {
@@ -1573,7 +1633,7 @@ function WorkspaceRowWithMenu({
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
return;
}
@@ -1585,11 +1645,13 @@ function WorkspaceRowWithMenu({
afterHide: redirectAfterArchive,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
);
} finally {
setIsArchivingWorkspace(false);
}
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
const handleArchiveWorkspace = useCallback(() => {
void hideWorkspaceAfterConfirmation();
@@ -1603,23 +1665,27 @@ function WorkspaceRowWithMenu({
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
toast.copied("Branch name copied");
}, [toast, workspace.name]);
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
}, [t, toast, workspace.name]);
const renameMutation = useMutation({
mutationFn: async (branch: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error("Host is not connected");
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
const targetCwd = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
@@ -1627,7 +1693,7 @@ function WorkspaceRowWithMenu({
});
const payload = await client.renameBranch({ cwd: targetCwd, branch });
if (!payload.success || payload.error) {
throw new Error(payload.error?.message ?? "Failed to rename branch");
throw new Error(payload.error?.message ?? t("sidebar.workspace.rename.invalidBranchName"));
}
return { targetCwd };
},
@@ -1654,11 +1720,14 @@ function WorkspaceRowWithMenu({
[renameMutation],
);
const validateRenameSlug = useCallback((value: string): string | null => {
const result = validateBranchSlug(slugify(value));
if (result.valid) return null;
return result.error ?? "Invalid branch name";
}, []);
const validateRenameSlug = useCallback(
(value: string): string | null => {
const result = validateBranchSlug(slugify(value));
if (result.valid) return null;
return result.error ?? t("sidebar.workspace.rename.invalidBranchName");
},
[t],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
@@ -1700,9 +1769,17 @@ function WorkspaceRowWithMenu({
isCreating={isCreating}
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
archiveLabel={
isWorktree
? t("sidebar.workspace.actions.archiveWorktree")
: t("sidebar.workspace.actions.hideFromSidebar")
}
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
archivePendingLabel={
isWorktree
? t("sidebar.workspace.actions.archiving")
: t("sidebar.workspace.actions.hiding")
}
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
@@ -1712,10 +1789,10 @@ function WorkspaceRowWithMenu({
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.name}
placeholder="branch-name"
submitLabel="Rename"
submitLabel={t("sidebar.workspace.rename.submit")}
validate={validateRenameSlug}
maxLength={MAX_SLUG_LENGTH}
onClose={handleCloseRename}
@@ -1751,6 +1828,7 @@ function NonGitProjectRowWithMenuContent({
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
const { t } = useTranslation();
const toast = useToast();
const contextMenu = useContextMenu();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
@@ -1769,10 +1847,12 @@ function NonGitProjectRowWithMenuContent({
void (async () => {
const confirmed = await confirmDialog({
title: "Hide workspace?",
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Hide",
cancelLabel: "Cancel",
title: t("sidebar.workspace.confirmations.hideTitle"),
message: t("sidebar.workspace.confirmations.hideMessage", {
workspaceName: workspace.name,
}),
confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"),
cancelLabel: t("sidebar.workspace.confirmations.cancel"),
destructive: true,
});
if (!confirmed) {
@@ -1781,7 +1861,7 @@ function NonGitProjectRowWithMenuContent({
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
return;
}
@@ -1794,13 +1874,15 @@ function NonGitProjectRowWithMenuContent({
afterHide: redirectAfterArchive,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
);
} finally {
setIsArchivingWorkspace(false);
}
})();
})();
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
return (
<>
@@ -1831,11 +1913,11 @@ function NonGitProjectRowWithMenuContent({
<ContextMenuItem
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
status={isArchivingWorkspace ? "pending" : "idle"}
pendingLabel="Hiding..."
pendingLabel={t("sidebar.workspace.actions.hiding")}
destructive
onSelect={handleArchiveWorkspace}
>
Hide from sidebar
{t("sidebar.workspace.actions.hideFromSidebar")}
</ContextMenuItem>
</ContextMenuContent>
</>
@@ -2212,6 +2294,7 @@ function ProjectBlock({
);
const toast = useToast();
const { t } = useTranslation();
const [isRemovingProject, setIsRemovingProject] = useState(false);
const handleRemoveProject = useCallback(() => {
@@ -2221,10 +2304,10 @@ function ProjectBlock({
void (async () => {
const confirmed = await confirmDialog({
title: "Remove project?",
message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Remove",
cancelLabel: "Cancel",
title: t("sidebar.project.confirmations.removeTitle"),
message: t("sidebar.project.confirmations.removeMessage", { projectName: displayName }),
confirmLabel: t("sidebar.project.confirmations.removeConfirm"),
cancelLabel: t("sidebar.project.confirmations.cancel"),
destructive: true,
});
if (!confirmed) {
@@ -2233,7 +2316,7 @@ function ProjectBlock({
const client = getHostRuntimeStore().getClient(serverId);
if (!client) {
toast.error("Host is not connected");
toast.error(t("sidebar.project.toasts.hostDisconnected"));
return;
}
@@ -2243,13 +2326,13 @@ function ProjectBlock({
workspaces: project.workspaces,
}).then((failures) => {
if (failures.length > 0) {
toast.error("Failed to remove some workspaces");
toast.error(t("sidebar.project.toasts.removeFailed"));
}
setIsRemovingProject(false);
return;
});
})();
}, [isRemovingProject, serverId, displayName, toast, project.workspaces]);
}, [isRemovingProject, serverId, displayName, t, toast, project.workspaces]);
const flattenedRowWorkspaceId =
rowModel.kind === "workspace_link" ? rowModel.workspace.workspaceId : null;
@@ -2476,6 +2559,7 @@ function ProjectModeList({
}: Omit<SidebarWorkspaceListProps, "groupMode" | "isRefreshing" | "onRefresh"> & {
pathname: string;
}) {
const { t } = useTranslation();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
@@ -2688,10 +2772,10 @@ function ProjectModeList({
<>
{projects.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyTitle}>No projects yet</Text>
<Text style={styles.emptyText}>Add a project to get started</Text>
<Text style={styles.emptyTitle}>{t("sidebar.project.empty.title")}</Text>
<Text style={styles.emptyText}>{t("sidebar.project.empty.description")}</Text>
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
Add project
{t("sidebar.actions.addProject")}
</Button>
</View>
) : (

View File

@@ -1,4 +1,5 @@
import { memo, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -348,6 +349,7 @@ function StatusWorkspaceRowWithMenu({
showShortcutBadge: boolean;
onPress: () => void;
}) {
const { t } = useTranslation();
const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const queryClient = useQueryClient();
@@ -419,7 +421,7 @@ function StatusWorkspaceRowWithMenu({
if (!confirmed) return;
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
toast.error(t("workspace.terminal.hostDisconnected"));
return;
}
setIsArchivingWorkspace(true);
@@ -434,7 +436,7 @@ function StatusWorkspaceRowWithMenu({
} finally {
setIsArchivingWorkspace(false);
}
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
@@ -459,7 +461,7 @@ function StatusWorkspaceRowWithMenu({
const renameMutation = useMutation({
mutationFn: async (branch: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error("Host is not connected");
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
const targetCwd = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,

View File

@@ -28,6 +28,7 @@ import {
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
@@ -660,6 +661,7 @@ function DragOverlayTabChipInner({
normalizedServerId: string;
normalizedWorkspaceId: string;
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const chipStyle = useMemo(
@@ -684,7 +686,8 @@ function DragOverlayTabChipInner({
workspaceId={normalizedWorkspaceId}
>
{(presentation) => {
const label = presentation.titleState === "loading" ? "Loading..." : presentation.label;
const label =
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
return (
<View style={chipStyle}>

View File

@@ -10,6 +10,7 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useAppVisible } from "@/hooks/use-app-visible";
@@ -169,6 +170,7 @@ export function TerminalPane({
onOpenFileExplorer,
onOpenWorkspaceFile,
}: TerminalPaneProps) {
const { t } = useTranslation();
const isAppVisible = useAppVisible();
const { theme } = useUnistyles();
const { settings } = useAppSettings();
@@ -750,7 +752,7 @@ export function TerminalPane({
if (!client || !isConnected) {
return (
<View style={styles.centerState}>
<Text style={styles.stateText}>Host is not connected</Text>
<Text style={styles.stateText}>{t("workspace.terminal.hostDisconnected")}</Text>
</View>
);
}

View File

@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
@@ -49,6 +50,7 @@ export function useToastHost(): {
dismiss: () => void;
} {
const { theme } = useUnistyles();
const { t } = useTranslation();
const [toast, setToast] = useState<ToastState | null>(null);
const idRef = useRef(0);
@@ -85,13 +87,13 @@ export function useToastHost(): {
() => ({
show,
copied: (label?: string) =>
show(label ? `Copied ${label}` : "Copied", {
show(label ? t("common.states.copiedLabel", { label }) : t("common.states.copied"), {
variant: "success",
icon: <CheckCircle2 size={18} color={theme.colors.foreground} />,
}),
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
}),
[show, theme.colors.foreground],
[show, theme.colors.foreground, t],
);
const dismiss = useCallback(() => {

View File

@@ -8,6 +8,8 @@ import {
} from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
@@ -240,11 +242,12 @@ function WorktreeSetupDetailSection({
function resolveSubAgentFallbackHeader(
subAgentType: string | null | undefined,
description: string | null | undefined,
fallbackText: string,
): string {
if (subAgentType && description) {
return `${subAgentType}: ${description}`;
}
return subAgentType ?? description ?? "Sub-agent activity";
return subAgentType ?? description ?? fallbackText;
}
interface SubAgentDetailProps {
@@ -365,8 +368,13 @@ function SubAgentDetailSection({
description,
ds,
}: SubAgentDetailProps) {
const { t } = useTranslation();
const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]);
const fallbackHeader = resolveSubAgentFallbackHeader(subAgentType, description);
const fallbackHeader = resolveSubAgentFallbackHeader(
subAgentType,
description,
t("toolCallDetails.subAgentActivity"),
);
const hasActions = actions.length > 0;
return (
<View style={ds.sectionFillStyle}>
@@ -594,7 +602,7 @@ interface UnknownDetail {
output: unknown;
}
function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNode[] {
function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles, t: TFunction): ReactNode[] {
const plainInputText =
typeof detail.input === "string" && detail.output === null ? detail.input : null;
@@ -609,8 +617,8 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNod
}
const sectionsFromTopLevel = [
{ title: "Input", value: detail.input },
{ title: "Output", value: detail.output },
{ title: t("toolCallDetails.input"), value: detail.input },
{ title: t("toolCallDetails.output"), value: detail.output },
].filter((entry) =>
hasMeaningfulToolCallDetail({
type: "unknown",
@@ -653,6 +661,7 @@ function buildDetailSections(
detail: ToolCallDetail | undefined,
diffLines: DiffLine[] | undefined,
ds: DetailStyles,
t: TFunction,
): ReactNode[] {
if (!detail) return [];
if (detail.type === "shell") {
@@ -723,15 +732,16 @@ function buildDetailSections(
return [<PlainTextSection key="plain-text" text={detail.text} />];
}
if (detail.type === "unknown") {
return buildUnknownSections(detail, ds);
return buildUnknownSections(detail, ds, t);
}
return [];
}
function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }) {
const { t } = useTranslation();
return (
<View style={styles.section}>
<Text style={SECTION_TITLE_ERROR_STYLE}>Error</Text>
<Text style={SECTION_TITLE_ERROR_STYLE}>{t("toolCallDetails.error")}</Text>
<ScrollView
horizontal
nestedScrollEnabled
@@ -772,11 +782,12 @@ function ToolCallDetailsContentInner({
fillAvailableHeight = false,
showLoadingSkeleton = false,
}: ToolCallDetailsContentProps) {
const { t } = useTranslation();
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
const ds = useDetailStyles(detail, resolvedMaxHeight, fillAvailableHeight);
const diffLines = useDiffLines(detail);
const sections: ReactNode[] = buildDetailSections(detail, diffLines, ds);
const sections: ReactNode[] = buildDetailSections(detail, diffLines, ds, t);
if (errorText) {
sections.push(<ErrorSection key="error" errorText={errorText} ds={ds} />);
@@ -786,7 +797,7 @@ function ToolCallDetailsContentInner({
if (showLoadingSkeleton) {
return <LoadingSkeleton containerStyle={ds.loadingContainerStyle} />;
}
return <Text style={styles.emptyStateText}>No additional details available</Text>;
return <Text style={styles.emptyStateText}>{t("toolCallDetails.empty")}</Text>;
}
return <View style={ds.fullBleedContainerStyle}>{sections}</View>;

View File

@@ -10,6 +10,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { File, Folder } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
@@ -122,11 +123,14 @@ export function Autocomplete({
onSelect,
isLoading = false,
errorMessage,
loadingText = "Loading...",
emptyText = "No results found",
loadingText,
emptyText,
maxHeight = 220,
}: AutocompleteProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const resolvedLoadingText = loadingText ?? t("common.states.loading");
const resolvedEmptyText = emptyText ?? t("common.empty.noResults");
const scrollRef = useRef<ScrollView>(null);
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
const viewportHeightRef = useRef(0);
@@ -213,7 +217,7 @@ export function Autocomplete({
return (
<View style={containerStyle}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>{loadingText}</Text>
<Text style={styles.emptyText}>{resolvedLoadingText}</Text>
</View>
</View>
);
@@ -233,7 +237,7 @@ export function Autocomplete({
return (
<View style={containerStyle}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>{emptyText}</Text>
<Text style={styles.emptyText}>{resolvedEmptyText}</Text>
</View>
</View>
);

View File

@@ -23,6 +23,7 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
@@ -1240,15 +1241,15 @@ export function Combobox({
renderOption,
onSearchQueryChange,
searchable = true,
placeholder = "Search...",
placeholder,
searchPlaceholder,
emptyText = "No options match your search.",
emptyText,
allowCustomValue = false,
customValuePrefix = "Use",
customValueDescription,
customValueKind,
optionsPosition = "below-search",
title = "Select",
title,
header,
mobileChildrenScrollEnabled = true,
presentation,
@@ -1263,7 +1264,11 @@ export function Combobox({
anchorRef,
children,
}: ComboboxProps): ReactElement | null {
const { t } = useTranslation();
const { theme } = useUnistyles();
const resolvedPlaceholder = placeholder ?? t("common.placeholders.search");
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
const resolvedTitle = title ?? t("common.actions.select");
const isMobile = useIsCompactFormFactor();
const titleColor = theme.colors.foreground;
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
@@ -1519,7 +1524,7 @@ export function Combobox({
[],
);
const effectiveSearchPlaceholder = searchPlaceholder ?? placeholder;
const effectiveSearchPlaceholder = searchPlaceholder ?? resolvedPlaceholder;
const hasChildren = Boolean(children);
if (isMobile) {
@@ -1531,7 +1536,7 @@ export function Combobox({
handleSheetDismiss={handleSheetDismiss}
handleIndicatorStyle={handleIndicatorStyle}
titleColor={titleColor}
title={title}
title={resolvedTitle}
header={header}
onClose={handleClose}
stickyHeader={stickyHeader}
@@ -1547,7 +1552,7 @@ export function Combobox({
orderedVisibleOptions={orderedVisibleOptions}
value={value}
activeIndex={activeIndex}
emptyText={emptyText}
emptyText={resolvedEmptyText}
handleSelect={handleSelect}
renderOption={renderOption}
>
@@ -1580,7 +1585,7 @@ export function Combobox({
orderedVisibleOptions={orderedVisibleOptions}
value={value}
activeIndex={activeIndex}
emptyText={emptyText}
emptyText={resolvedEmptyText}
handleSelect={handleSelect}
renderOption={renderOption}
hasChildren={hasChildren}

View File

@@ -12,6 +12,7 @@ import {
type ReactNode,
type Ref,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Dimensions,
@@ -385,6 +386,7 @@ export function ContextMenuContent({
mobileMode?: MobileMenuMode;
testID?: string;
}>): ReactElement | null {
const { t } = useTranslation();
const context = useContextMenuContext("ContextMenuContent");
const { theme } = useUnistyles();
const webScrollbarStyle = useWebScrollbarStyle();
@@ -560,7 +562,7 @@ export function ContextMenuContent({
<View style={styles.overlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Menu backdrop"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}

View File

@@ -11,6 +11,7 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Modal,
@@ -442,6 +443,7 @@ export function DropdownMenuContent({
scrollable?: boolean;
testID?: string;
}>): ReactElement | null {
const { t } = useTranslation();
const { open, setOpen, triggerRef, flushPendingSelect } =
useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false);
@@ -625,7 +627,7 @@ export function DropdownMenuContent({
<View style={styles.overlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Menu backdrop"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -157,6 +158,7 @@ export interface WelcomeScreenProps {
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const appVersion = resolveAppVersion();
@@ -206,7 +208,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
? [
{
key: "direct-connection",
label: "Direct connection",
label: t("pairing.connectionMethods.direct.title"),
testID: "welcome-direct-connection",
primary: true,
icon: Link2,
@@ -214,7 +216,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
label: t("pairing.connectionMethods.pasteLink.title"),
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
@@ -224,7 +226,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
: [
{
key: "scan-qr",
label: "Scan QR code",
label: t("pairing.connectionMethods.scanQr.title"),
testID: "welcome-scan-qr",
primary: true,
icon: QrCode,
@@ -232,7 +234,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
{
key: "direct-connection",
label: "Direct connection",
label: t("pairing.connectionMethods.direct.title"),
testID: "welcome-direct-connection",
primary: false,
icon: Link2,
@@ -240,7 +242,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
label: t("pairing.connectionMethods.pasteLink.title"),
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
@@ -264,8 +266,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<View style={styles.content}>
<PaseoLogo size={96} />
<View style={styles.copyBlock}>
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Connect your computer to get started</Text>
<Text style={styles.title}>{t("onboarding.title")}</Text>
<Text style={styles.subtitle}>{t("onboarding.subtitle")}</Text>
{isNative ? (
<Pressable style={styles.setupLink} onPress={handleOpenPaseoSite}>
<Text style={styles.setupLinkText}>paseo.sh</Text>
@@ -288,7 +290,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
style={styles.settingsButton}
testID="welcome-open-settings"
>
Settings
{t("onboarding.actions.settings")}
</Button>
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>

View File

@@ -9,6 +9,7 @@ import {
type ReactNode,
} from "react";
import { Dimensions, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native";
@@ -212,6 +213,7 @@ function WorkspaceHoverCardContent({
triggerRef: React.RefObject<View | null>;
contentRef: React.RefObject<View | null>;
}): ReactElement | null {
const { t } = useTranslation();
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
@@ -274,7 +276,7 @@ function WorkspaceHoverCardContent({
collapsable={false}
onLayout={handleLayout}
accessibilityRole="menu"
accessibilityLabel="Workspace scripts"
accessibilityLabel={t("workspace.hoverCard.scriptsAccessibility")}
testID="workspace-hover-card"
style={styles.card}
frameStyle={frameStyle}
@@ -373,6 +375,7 @@ function ChecksSummaryContent({
checks: NonNullable<PrHint["checks"]>;
hovered: boolean;
}) {
const { t } = useTranslation();
const { passed, failed, pending } = getChecksSummaryCounts(checks);
const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
@@ -385,7 +388,7 @@ function ChecksSummaryContent({
) : (
<ThemedGitHubIcon size={12} uniProps={iconUniProps} />
)}
<Text style={labelStyle}>Checks</Text>
<Text style={labelStyle}>{t("workspace.git.pr.sections.checks")}</Text>
<View style={styles.checksSummaryCounts}>
<ChecksSummaryPill count={passed} kind="passed" />
<ChecksSummaryPill count={failed} kind="failed" />

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/composer";
@@ -96,8 +97,13 @@ async function callWorkspaceCreation({
return connectedClient.openProject(input.cwd);
}
function failureMessageForCreationMethod(method: "create_worktree" | "open_project") {
return method === "create_worktree" ? "Failed to create worktree" : "Failed to open project";
function failureMessageForCreationMethod(
method: "create_worktree" | "open_project",
t: ReturnType<typeof useTranslation>["t"],
) {
return method === "create_worktree"
? t("workspaceSetup.errors.failedCreateWorktree")
: t("workspaceSetup.errors.failedOpenProject");
}
function buildCreateAgentOptions({
@@ -140,6 +146,7 @@ function buildCreateAgentOptions({
}
export function WorkspaceSetupDialog() {
const { t } = useTranslation();
const toast = useToast();
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
@@ -170,7 +177,7 @@ export function WorkspaceSetupDialog() {
});
const composerState = chatDraft.composerState;
if (!composerState && pendingWorkspaceSetup) {
throw new Error("Workspace setup composer state is required");
throw new Error(t("workspaceSetup.errors.composerStateRequired"));
}
const { icon: projectIcon } = useProjectIconQuery({
@@ -218,15 +225,15 @@ export function WorkspaceSetupDialog() {
const withConnectedClient = useCallback(() => {
if (!client || !isConnected) {
throw new Error("Host is not connected");
throw new Error(t("workspaceSetup.errors.hostDisconnected"));
}
return client;
}, [client, isConnected]);
}, [client, isConnected, t]);
const ensureWorkspace = useCallback(
async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => {
if (!pendingWorkspaceSetup) {
throw new Error("No workspace setup is pending");
throw new Error(t("workspaceSetup.errors.pendingRequired"));
}
if (createdWorkspace) {
@@ -242,7 +249,7 @@ export function WorkspaceSetupDialog() {
if (payload.error || !payload.workspace) {
throw new Error(
payload.error ?? failureMessageForCreationMethod(pendingWorkspaceSetup.creationMethod),
payload.error ?? failureMessageForCreationMethod(pendingWorkspaceSetup.creationMethod, t),
);
}
@@ -259,6 +266,7 @@ export function WorkspaceSetupDialog() {
mergeWorkspaces,
pendingWorkspaceSetup,
setHasHydratedWorkspaces,
t,
withConnectedClient,
],
);
@@ -284,10 +292,10 @@ export function WorkspaceSetupDialog() {
const ensuredWorkspace = await ensureWorkspace({ cwd, attachments });
const connectedClient = withConnectedClient();
if (!composerState) {
throw new Error("Workspace setup composer state is required");
throw new Error(t("workspaceSetup.errors.composerStateRequired"));
}
if (!composerState.selectedProvider) {
throw new Error("Select a model");
throw new Error(t("workspaceSetup.errors.selectModel"));
}
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
@@ -334,6 +342,7 @@ export function WorkspaceSetupDialog() {
serverId,
setAgents,
ensureWorkspace,
t,
toast,
withConnectedClient,
],
@@ -392,8 +401,8 @@ export function WorkspaceSetupDialog() {
);
const sheetHeader = useMemo<SheetHeader>(
() => ({ title: "Create workspace", subtitle: subtitleContent }),
[subtitleContent],
() => ({ title: t("workspaceSetup.title"), subtitle: subtitleContent }),
[subtitleContent, t],
);
if (!pendingWorkspaceSetup || !sourceDirectory) {

View File

@@ -1,4 +1,5 @@
import type { PaseoConfigRaw } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
export interface WorktreeSetupWorkspaceInput {
@@ -64,10 +65,9 @@ export function buildWorktreeSetupCalloutPolicy(
id: calloutKey,
dismissalKey: calloutKey,
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
title: i18n.t("sidebar.worktreeSetup.title"),
description: i18n.t("sidebar.worktreeSetup.description"),
actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"),
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
testID: `worktree-setup-callout-${project.projectKey}`,
};

View File

@@ -17,6 +17,7 @@ import {
type UserMessageItem,
} from "@/types/stream";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
import { i18n } from "@/i18n/i18next";
export interface QueuedComposerMessage {
id: string;
@@ -244,6 +245,7 @@ export interface SendQueuedComposerMessageNowInput {
messageId: string;
queue: QueueWriter;
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
failedToSendMessage?: string;
}
export type SendQueuedComposerMessageNowResult =
@@ -275,7 +277,10 @@ export async function sendQueuedComposerMessageNow(
});
return {
status: "failed",
errorMessage: error instanceof Error ? error.message : "Failed to send message",
errorMessage:
error instanceof Error
? error.message
: (input.failedToSendMessage ?? i18n.t("composer.errors.failedToSend")),
};
}
}

View File

@@ -8,6 +8,7 @@ import {
type ReactNode,
type RefObject,
} from "react";
import { useTranslation } from "react-i18next";
import {
View,
Text,
@@ -56,7 +57,7 @@ import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manife
import {
getFeatureHighlightColor,
getFeatureTooltip,
getAgentControlHint,
getAgentControlHintKey,
formatThinkingOptionLabel,
resolveAgentModelSelection,
} from "@/composer/agent-controls/utils";
@@ -415,6 +416,7 @@ function ControlledAgentControls({
isCompactLayout,
}: ControlledAgentControlsProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
@@ -432,7 +434,11 @@ function ControlledAgentControls({
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0,
);
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
const displayProvider = findOptionLabel(
providerOptions,
selectedProviderId,
t("agentControls.provider.fallback"),
);
const formattedThinkingOptions = useMemo(
() => toThinkingControlOptions(thinkingOptions),
[thinkingOptions],
@@ -440,7 +446,7 @@ function ControlledAgentControls({
const displayThinking = findOptionLabel(
formattedThinkingOptions,
selectedThinkingOptionId,
formattedThinkingOptions[0]?.label ?? "Unknown",
formattedThinkingOptions[0]?.label ?? t("agentControls.thinking.unknown"),
);
const ProviderIcon = resolveProviderIcon(provider);
@@ -709,6 +715,7 @@ const DESKTOP_SEARCH_THRESHOLD = 6;
function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const {
provider,
providerOptions,
@@ -764,7 +771,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
onPress={handleProviderPress}
style={providerPressableStyle}
accessibilityRole="button"
accessibilityLabel="Select agent provider"
accessibilityLabel={t("agentControls.provider.select")}
testID="agent-provider-selector"
>
<Text style={styles.modeBadgeText}>{displayProvider}</Text>
@@ -805,7 +812,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getAgentControlHint("model")}</Text>
<Text style={styles.tooltipText}>{t(getAgentControlHintKey("model"))}</Text>
</TooltipContent>
</Tooltip>
) : null}
@@ -821,7 +828,9 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
onPress={handleThinkingPress}
style={thinkingPressableStyle}
accessibilityRole="button"
accessibilityLabel={`Select thinking option (${displayThinking})`}
accessibilityLabel={t("agentControls.thinking.selectWithValue", {
value: displayThinking,
})}
testID="agent-thinking-selector"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -830,7 +839,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getAgentControlHint("thinking")}</Text>
<Text style={styles.tooltipText}>{t(getAgentControlHintKey("thinking"))}</Text>
</TooltipContent>
</Tooltip>
<Combobox
@@ -901,6 +910,7 @@ interface SheetAgentControlsContentProps {
function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const {
provider,
selectedModelId,
@@ -936,6 +946,10 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
const hasThinking = comboboxThinkingOptions.length > 0;
const hasFeatures = Boolean(features && features.length > 0);
const featuresSheetHeader = useMemo<SheetHeader>(
() => ({ title: t("agentControls.features.title") }),
[t],
);
const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]);
const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]);
@@ -1012,7 +1026,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
disabled={disabled || !canSelectThinking}
style={thinkingButtonStyle}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
accessibilityLabel={t("agentControls.thinking.select")}
testID="agent-controls-thinking"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -1025,7 +1039,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
disabled={disabled}
style={featuresButtonStyle}
accessibilityRole="button"
accessibilityLabel="Open agent features"
accessibilityLabel={t("agentControls.features.open")}
testID="agent-controls-features"
>
<Settings2 size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -1038,7 +1052,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
value={selectedThinkingOptionId ?? ""}
onSelect={handleSelectThinkingAndClose}
searchable={false}
title="Thinking"
title={t("agentControls.thinking.title")}
open={activeSheet === "thinking"}
onOpenChange={handleThinkingSheetOpenChange}
anchorRef={thinkingAnchorRef}
@@ -1047,7 +1061,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
) : null}
<AdaptiveModalSheet
header={FEATURES_SHEET_HEADER}
header={featuresSheetHeader}
visible={activeSheet === "features"}
onClose={handleCloseSheet}
testID="agent-features-sheet"
@@ -1206,6 +1220,7 @@ function SheetFeatureItem({
onSetFeature?: (featureId: string, value: unknown) => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const featureSelector: AgentControlSelector = `feature-${feature.id}`;
const handleFeatureOpenChange = useMemo(
@@ -1257,7 +1272,9 @@ function SheetFeatureItem({
)}
/>
<Text style={styles.sheetSelectText}>{feature.label}</Text>
<Text style={styles.modeBadgeText}>{feature.value ? "On" : "Off"}</Text>
<Text style={styles.modeBadgeText}>
{feature.value ? t("agentControls.features.on") : t("agentControls.features.off")}
</Text>
</Pressable>
</View>
);
@@ -1344,8 +1361,6 @@ function ThinkingComboboxOption({
);
}
const FEATURES_SHEET_HEADER: SheetHeader = { title: "Features" };
export const AgentControls = memo(function AgentControls({
agentId,
serverId,

View File

@@ -7,6 +7,7 @@ import {
type ComponentType,
type ReactElement,
} from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
@@ -108,6 +109,7 @@ function AgentModeControlView({
disabled = false,
}: AgentModeControlViewProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -182,14 +184,14 @@ function AgentModeControlView({
const sheetHeader = useMemo<SheetHeader>(
() => ({
title: "Mode",
title: t("agentControls.mode.title"),
search: {
onChange: setSearchQuery,
placeholder: "Search modes...",
placeholder: t("agentControls.mode.searchPlaceholder"),
testID: "mode-search-input",
},
}),
[],
[t],
);
if (!selectedMode) return null;
@@ -203,7 +205,9 @@ function AgentModeControlView({
onPress={handlePress}
style={pressableStyle}
accessibilityRole="button"
accessibilityLabel={`Select agent mode (${selectedModeLabel})`}
accessibilityLabel={t("agentControls.mode.selectWithValue", {
value: selectedModeLabel,
})}
testID="mode-control"
>
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}

View File

@@ -3,17 +3,17 @@ import {
formatAgentModeLabel,
getFeatureHighlightColor,
getFeatureTooltip,
getAgentControlHint,
getAgentControlHintKey,
formatThinkingOptionLabel,
normalizeModelId,
resolveAgentModelSelection,
} from "./utils";
describe("getAgentControlHint", () => {
it("explains what each editable agent control does", () => {
expect(getAgentControlHint("thinking")).toBe("Thinking mode");
expect(getAgentControlHint("model")).toBe("Change model");
expect(getAgentControlHint("mode")).toBe("Change permission mode");
describe("getAgentControlHintKey", () => {
it("returns translation keys for each editable agent control hint", () => {
expect(getAgentControlHintKey("thinking")).toBe("agentControls.hints.thinking");
expect(getAgentControlHintKey("model")).toBe("agentControls.hints.model");
expect(getAgentControlHintKey("mode")).toBe("agentControls.hints.mode");
});
});

View File

@@ -1,16 +1,21 @@
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { i18n } from "@/i18n/i18next";
export type ExplainedAgentControl = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow";
export type AgentControlHintKey =
| "agentControls.hints.thinking"
| "agentControls.hints.model"
| "agentControls.hints.mode";
export function getAgentControlHint(selector: ExplainedAgentControl): string {
export function getAgentControlHintKey(selector: ExplainedAgentControl): AgentControlHintKey {
switch (selector) {
case "thinking":
return "Thinking mode";
return "agentControls.hints.thinking";
case "model":
return "Change model";
return "agentControls.hints.model";
case "mode":
return "Change permission mode";
return "agentControls.hints.mode";
default:
throw new Error("unreachable");
}
@@ -79,7 +84,7 @@ export function formatThinkingOptionLabel(option: ControlLabelInput): string {
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
if (compactId === "xhigh" || compactLabel === "xhigh") {
return "Extra high";
return i18n.t("agentControls.thinking.extraHigh");
}
return formatControlLabel(option, true);
@@ -143,17 +148,19 @@ function resolveModelDisplay(
selectedModel: AgentModelDefinition | null,
preferredModelId: string | null,
fallbackModel: AgentModelDefinition | null,
unknownModelLabel: string,
): { activeModelId: string | null; displayModel: string } {
return {
activeModelId: selectedModel?.id ?? preferredModelId ?? null,
displayModel:
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model",
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? unknownModelLabel,
};
}
function resolveThinkingDisplay(
effectiveThinking: ThinkingOption | null,
selectedThinkingId: string | null,
unknownThinkingLabel: string,
): string {
if (effectiveThinking) {
return formatThinkingOptionLabel(effectiveThinking);
@@ -163,7 +170,7 @@ function resolveThinkingDisplay(
return formatThinkingOptionLabel({ id: selectedThinkingId });
}
return "Unknown";
return unknownThinkingLabel;
}
export function resolveAgentModelSelection(input: {
@@ -189,13 +196,18 @@ export function resolveAgentModelSelection(input: {
selectedModel,
preferredModelId,
fallbackModel,
i18n.t("agentControls.model.unknown"),
);
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel);
const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId);
const selectedThinkingId = effectiveThinking?.id ?? null;
const displayThinking = resolveThinkingDisplay(effectiveThinking, selectedThinkingId);
const displayThinking = resolveThinkingDisplay(
effectiveThinking,
selectedThinkingId,
i18n.t("agentControls.thinking.unknown"),
);
return {
selectedModel,

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MessageSquareCode, MousePointer2 } from "lucide-react-native";
import type {
@@ -231,14 +232,15 @@ function WorkspaceAttachmentPill({
onOpen,
onRemove,
}: WorkspaceAttachmentPillProps) {
const { t } = useTranslation();
let label: string;
if (attachment.kind === "browser_element") {
label = `Element · ${attachment.attachment.tag}`;
label = t("composer.attachments.browserElement", { tag: attachment.attachment.tag });
} else {
label =
attachment.commentCount === 1
? "Review · 1 comment"
: `Review · ${attachment.commentCount} comments`;
? t("message.attachments.reviewOne")
: t("message.attachments.reviewMany", { count: attachment.commentCount });
}
const handleOpen = useCallback(() => {
onOpen(attachment);
@@ -253,13 +255,13 @@ function WorkspaceAttachmentPill({
onRemove={handleRemove}
openAccessibilityLabel={
attachment.kind === "browser_element"
? "Open browser element attachment"
: "Open review attachment"
? t("composer.attachments.openBrowserElement")
: t("composer.attachments.openReview")
}
removeAccessibilityLabel={
attachment.kind === "browser_element"
? "Remove browser element attachment"
: "Remove review attachment"
? t("composer.attachments.removeBrowserElement")
: t("composer.attachments.removeReview")
}
disabled={disabled}
>

View File

@@ -1,4 +1,5 @@
import { useCallback, useMemo, useReducer } from "react";
import { useTranslation } from "react-i18next";
import type { ComposerAttachment } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -105,6 +106,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onCreateSuccess,
onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
const { t } = useTranslation();
const [machine, dispatch] = useReducer(
reducer,
initialAttempt,
@@ -163,7 +165,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error("No host selected");
const error = new Error(t("composer.errors.noHostSelected"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
@@ -204,7 +206,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
await onCreateSuccess({ result: createResult.result, attempt });
} catch (error) {
const resolved = error instanceof Error ? error : new Error("Failed to create agent");
const resolved =
error instanceof Error ? error : new Error(t("composer.errors.failedToCreateAgent"));
dispatch({ type: "CREATE_FAILED", message: resolved.message });
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
clearPendingCreateAttempt({ draftId });
@@ -222,6 +225,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onBeforeSubmit,
onCreateError,
onCreateSuccess,
t,
updatePendingAgentId,
],
);
@@ -229,7 +233,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => {
if (isSubmitting) {
throw new Error("Already loading");
throw new Error(t("composer.errors.alreadyLoading"));
}
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
@@ -238,7 +242,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const trimmedPrompt = text.trim();
if (!trimmedPrompt && !allowEmptyText) {
const error = new Error("Initial prompt is required");
const error = new Error(t("composer.errors.initialPromptRequired"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
@@ -256,7 +260,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error("No host selected");
const error = new Error(t("composer.errors.noHostSelected"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
@@ -294,6 +298,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onCreateStart,
runCreateAttempt,
setPendingCreateAttempt,
t,
validateBeforeSubmit,
],
);

View File

@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Import as ImportIcon } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
@@ -13,6 +14,7 @@ interface ComposerImportPillProps {
}
export function ComposerImportPill({ onPress, disabled = false }: ComposerImportPillProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
@@ -22,7 +24,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
<Pressable
testID="composer-import-agent-pill"
accessibilityRole="button"
accessibilityLabel="Import session"
accessibilityLabel={t("importSession.title")}
onPress={onPress}
disabled={disabled}
onHoverIn={handleHoverIn}
@@ -31,7 +33,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
>
<ThemedImportIcon size={14} uniProps={iconColorMapping} />
<Text style={styles.label} numberOfLines={1}>
Import session
{t("importSession.title")}
</Text>
</Pressable>
</View>

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, ScrollView, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -130,6 +131,8 @@ async function submitDraftCreateRequest(input: {
effectiveThinkingOptionId: string | null;
featureValues: Record<string, unknown> | undefined;
};
hostDisconnectedMessage: string;
selectModelMessage: string;
}): Promise<{ agentId: string | null; result: AgentSnapshotPayload }> {
const {
attempt,
@@ -146,12 +149,12 @@ async function submitDraftCreateRequest(input: {
invariant(workspaceDirectory, "Workspace directory is required");
invariant(workspaceExecutionAuthority, "Workspace authority is required");
if (!client) {
throw new Error("Host is not connected");
throw new Error(input.hostDisconnectedMessage);
}
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
throw new Error(input.selectModelMessage);
}
const modeIdOverride = resolveDraftModeIdOverride({
autoSubmitConfig,
@@ -199,6 +202,7 @@ function buildDraftAgentSnapshot(input: {
selectedProvider: string | null;
agentControls: { features?: Agent["features"] };
};
selectModelMessage: string;
}): Agent {
const { attempt, serverId, tabId, workspaceDirectory, autoSubmitConfig, composerState } = input;
invariant(workspaceDirectory, "Workspace directory is required");
@@ -213,7 +217,7 @@ function buildDraftAgentSnapshot(input: {
});
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
throw new Error(input.selectModelMessage);
}
return {
serverId,
@@ -309,6 +313,7 @@ export function WorkspaceDraftAgentTab({
onOpenWorkspaceFile,
onOpenImportSheet,
}: WorkspaceDraftAgentTabProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -456,6 +461,7 @@ export function WorkspaceDraftAgentTab({
workspaceDirectory: draftWorkingDirectory,
autoSubmitConfig,
composerState,
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}),
createRequest: async ({ attempt, text, images, attachments }) =>
submitDraftCreateRequest({
@@ -468,6 +474,8 @@ export function WorkspaceDraftAgentTab({
workspaceExecutionAuthority,
autoSubmitConfig,
composerState,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}),
onCreateSuccess: ({ result }) => {
clearDraftInput("sent");

View File

@@ -6,6 +6,7 @@ import {
Image,
type PressableStateCallbackType,
} from "react-native";
import type { TFunction } from "i18next";
import {
useState,
useEffect,
@@ -16,6 +17,7 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow";
@@ -144,8 +146,10 @@ function resolveCompactLayout(override: boolean | undefined, formFactor: boolean
return override ?? formFactor;
}
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean, t: TFunction): string {
return isDesktopWebBreakpoint
? t("composer.placeholders.desktop")
: t("composer.placeholders.mobile");
}
function resolveGithubSearchEnabled(
@@ -252,6 +256,12 @@ interface RenderAttachmentTrayArgs {
isComposerLocked: boolean;
handleOpenAttachment: (attachment: ComposerAttachment) => void;
handleRemoveAttachment: (index: number) => void;
labels: {
openImage: string;
removeImage: string;
openGithub: (kind: string, number: number) => string;
removeGithub: (kind: string, number: number) => string;
};
}
function renderComposerFooter(
@@ -272,8 +282,13 @@ function renderComposerFooter(
}
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
const { selectedAttachments, isComposerLocked, handleOpenAttachment, handleRemoveAttachment } =
args;
const {
selectedAttachments,
isComposerLocked,
handleOpenAttachment,
handleRemoveAttachment,
labels,
} = args;
if (selectedAttachments.length === 0) return null;
return (
<View style={styles.attachmentTray} testID="composer-attachment-tray">
@@ -284,6 +299,7 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu
disabled: isComposerLocked,
onOpen: handleOpenAttachment,
onRemove: handleRemoveAttachment,
labels,
}),
)}
</View>
@@ -294,10 +310,13 @@ interface RenderQueueTrackArgs {
queuedMessages: readonly QueuedMessage[];
handleEditQueuedMessage: (id: string) => void;
handleSendQueuedNow: (id: string) => Promise<void>;
editLabel: string;
sendNowLabel: string;
}
function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow } = args;
const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow, editLabel, sendNowLabel } =
args;
if (queuedMessages.length === 0) return null;
return (
<View style={styles.queueTrack}>
@@ -307,6 +326,8 @@ function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
item={item}
onEdit={handleEditQueuedMessage}
onSendNow={handleSendQueuedNow}
editLabel={editLabel}
sendNowLabel={sendNowLabel}
/>
))}
</View>
@@ -319,10 +340,11 @@ interface RenderComposerAttachmentPillArgs {
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
labels: RenderAttachmentTrayArgs["labels"];
}
function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): ReactElement {
const { attachment, index, disabled, onOpen, onRemove } = args;
const { attachment, index, disabled, onOpen, onRemove, labels } = args;
if (attachment.kind === "image") {
return (
<ImageAttachmentPill
@@ -332,6 +354,8 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
disabled={disabled}
onOpen={onOpen}
onRemove={onRemove}
openLabel={labels.openImage}
removeLabel={labels.removeImage}
/>
);
}
@@ -352,6 +376,8 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
disabled={disabled}
onOpen={onOpen}
onRemove={onRemove}
openLabel={labels.openGithub}
removeLabel={labels.removeGithub}
/>
);
}
@@ -472,9 +498,17 @@ interface QueuedMessageRowProps {
item: QueuedMessage;
onEdit: (id: string) => void;
onSendNow: (id: string) => void;
editLabel: string;
sendNowLabel: string;
}
function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
function QueuedMessageRow({
item,
onEdit,
onSendNow,
editLabel,
sendNowLabel,
}: QueuedMessageRowProps) {
const handleEdit = useCallback(() => {
onEdit(item.id);
}, [onEdit, item.id]);
@@ -490,7 +524,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
<Pressable
onPress={handleEdit}
style={styles.queueActionButton}
accessibilityLabel="Edit queued message"
accessibilityLabel={editLabel}
accessibilityRole="button"
>
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
@@ -498,7 +532,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
<Pressable
onPress={handleSendNow}
style={QUEUE_SEND_BUTTON_STYLE}
accessibilityLabel="Send queued message now"
accessibilityLabel={sendNowLabel}
accessibilityRole="button"
>
<ThemedArrowUp size={ICON_SIZE.sm} uniProps={iconAccentForegroundMapping} />
@@ -523,6 +557,8 @@ interface ImageAttachmentPillProps {
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
openLabel: string;
removeLabel: string;
}
function ImageAttachmentPill({
@@ -531,6 +567,8 @@ function ImageAttachmentPill({
disabled,
onOpen,
onRemove,
openLabel,
removeLabel,
}: ImageAttachmentPillProps) {
const handleOpen = useCallback(() => {
onOpen(attachment);
@@ -543,8 +581,8 @@ function ImageAttachmentPill({
testID="composer-image-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel="Open image attachment"
removeAccessibilityLabel="Remove image attachment"
openAccessibilityLabel={openLabel}
removeAccessibilityLabel={removeLabel}
disabled={disabled}
>
<ImageAttachmentThumbnail image={attachment.metadata} />
@@ -558,6 +596,8 @@ interface GithubAttachmentPillProps {
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
openLabel: (kind: string, number: number) => string;
removeLabel: (kind: string, number: number) => string;
}
function GithubAttachmentPill({
@@ -566,6 +606,8 @@ function GithubAttachmentPill({
disabled,
onOpen,
onRemove,
openLabel,
removeLabel,
}: GithubAttachmentPillProps) {
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
@@ -580,8 +622,8 @@ function GithubAttachmentPill({
testID="composer-github-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
openAccessibilityLabel={openLabel(kindLabel, item.number)}
removeAccessibilityLabel={removeLabel(kindLabel, item.number)}
disabled={disabled}
>
<View style={styles.githubPillBody}>
@@ -693,8 +735,6 @@ interface ComposerProps {
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
const StableMessageInput = memo(MessageInput);
function resolveContextWindowValues(
@@ -714,6 +754,7 @@ interface ComposerCancelButtonProps {
isConnected: boolean;
isCancellingAgent: boolean;
agentInterruptKeys: ReturnType<typeof useShortcutKeys>;
t: TFunction;
}
function ComposerCancelButton({
@@ -723,8 +764,11 @@ function ComposerCancelButton({
isConnected,
isCancellingAgent,
agentInterruptKeys,
t,
}: ComposerCancelButtonProps) {
const accessibilityLabel = isCancellingAgent ? "Canceling agent" : "Stop agent";
const accessibilityLabel = isCancellingAgent
? t("composer.cancel.cancelingAgent")
: t("composer.cancel.stopAgent");
const icon = isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
@@ -744,7 +788,7 @@ function ComposerCancelButton({
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
<Text style={styles.tooltipText}>{t("composer.cancel.interrupt")}</Text>
{shortcutNode}
</View>
</TooltipContent>
@@ -777,6 +821,7 @@ interface ComposerVoiceModeButtonProps {
state: PressableStateCallbackType & { hovered?: boolean },
) => (object | undefined)[];
voiceToggleKeys: ReturnType<typeof useShortcutKeys>;
t: TFunction;
}
interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
@@ -819,6 +864,7 @@ function ComposerVoiceModeButton({
isVoiceSwitching,
realtimeVoiceButtonStyle,
voiceToggleKeys,
t,
}: ComposerVoiceModeButtonProps) {
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
const renderTriggerContent = useCallback(
@@ -836,7 +882,7 @@ function ComposerVoiceModeButton({
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || isVoiceSwitching}
accessibilityLabel="Enable Voice mode"
accessibilityLabel={t("composer.voice.enableVoiceMode")}
accessibilityRole="button"
style={realtimeVoiceButtonStyle}
>
@@ -844,7 +890,7 @@ function ComposerVoiceModeButton({
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text>
<Text style={styles.tooltipText}>{t("composer.voice.voiceMode")}</Text>
{shortcutNode}
</View>
</TooltipContent>
@@ -887,6 +933,7 @@ export function Composer({
externalKeyboardShift,
isCompactLayout: isCompactLayoutOverride,
}: ComposerProps) {
const { t } = useTranslation();
const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -920,7 +967,7 @@ export function Composer({
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout);
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t);
const userInput = value;
const setUserInput = onChangeText;
const {
@@ -1070,11 +1117,11 @@ export function Composer({
return;
}
if (!sendAgentMessageRef.current) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
await sendAgentMessageRef.current(agentIdRef.current, text, submitAttachments);
},
[cwd, onMessageSent],
[cwd, onMessageSent, t],
);
useEffect(() => {
@@ -1088,7 +1135,7 @@ export function Composer({
sendAttachments: ComposerAttachment[],
) => {
if (!client) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const stream: AgentStreamWriter = {
getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id),
@@ -1106,7 +1153,7 @@ export function Composer({
});
onAttentionPromptSend?.();
};
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead]);
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead, t]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;
@@ -1181,6 +1228,7 @@ export function Composer({
onSubmitError: (error) => {
console.error("[AgentInput] Failed to send message:", error);
},
failedToSendMessage: t("composer.errors.failedToSend"),
});
completeSubmit({
result,
@@ -1198,6 +1246,7 @@ export function Composer({
setUserInput,
submitBehavior,
submitMessage,
t,
],
);
@@ -1376,12 +1425,13 @@ export function Composer({
queue: queueWriter,
submitMessage: ({ text, attachments: queuedAttachments }) =>
submitMessage(text, queuedAttachments),
failedToSendMessage: t("composer.errors.failedToSend"),
});
if (result.status === "failed") {
setSendError(result.errorMessage);
}
},
[agentId, queueWriter, submitMessage],
[agentId, queueWriter, submitMessage, t],
);
const handleQueue = useCallback(
@@ -1433,6 +1483,7 @@ export function Composer({
isConnected={isConnected}
isCancellingAgent={isCancellingAgent}
agentInterruptKeys={agentInterruptKeys}
t={t}
/>
),
[
@@ -1445,6 +1496,7 @@ export function Composer({
isCancellingAgent,
isConnected,
isProcessing,
t,
],
);
@@ -1463,6 +1515,7 @@ export function Composer({
isVoiceSwitching={isVoiceSwitching}
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
voiceToggleKeys={voiceToggleKeys}
t={t}
cancelButton={cancelButton}
/>
),
@@ -1479,6 +1532,7 @@ export function Composer({
isVoiceModeForAgent,
isVoiceSwitching,
realtimeVoiceButtonStyle,
t,
voiceToggleKeys,
],
);
@@ -1528,7 +1582,7 @@ export function Composer({
() => [
{
id: "image",
label: "Add image",
label: t("composer.attachments.addImage"),
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
void handlePickImage();
@@ -1536,14 +1590,14 @@ export function Composer({
},
{
id: "github",
label: "Add issue or PR",
label: t("composer.attachments.addIssueOrPr"),
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
setIsGithubPickerOpen(true);
},
},
],
[handlePickImage],
[handlePickImage, t],
);
const handleToggleGithubItem = useCallback(
@@ -1641,13 +1695,28 @@ export function Composer({
isComposerLocked,
handleOpenAttachment,
handleRemoveAttachment,
labels: {
openImage: t("composer.attachments.openImage"),
removeImage: t("composer.attachments.removeImage"),
openGithub: (kind: string, number: number) =>
t("composer.attachments.openGithub", { kind, number }),
removeGithub: (kind: string, number: number) =>
t("composer.attachments.removeGithub", { kind, number }),
},
}),
[handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments],
[handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t],
);
const queueList = useMemo(
() => renderQueueTrack({ queuedMessages, handleEditQueuedMessage, handleSendQueuedNow }),
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages],
() =>
renderQueueTrack({
queuedMessages,
handleEditQueuedMessage,
handleSendQueuedNow,
editLabel: t("composer.attachments.editQueuedMessage"),
sendNowLabel: t("composer.attachments.sendQueuedMessageNow"),
}),
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages, t],
);
const messageInputContainerRef = useRef<View>(null);
@@ -1660,8 +1729,8 @@ export function Composer({
[sendError],
);
const githubEmptyText = githubSearchResultsQuery.isFetching
? "Searching..."
: "No results found.";
? t("composer.github.searching")
: t("composer.github.noResults");
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
return (
@@ -1732,8 +1801,8 @@ export function Composer({
onSelect={noop}
keepOpenOnSelect
searchable
searchPlaceholder="Search issues and PRs..."
title="Attach issue or PR"
searchPlaceholder={t("composer.github.searchPlaceholder")}
title={t("composer.github.title")}
open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery}

View File

@@ -22,6 +22,7 @@ import {
forwardRef,
} from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
@@ -60,6 +61,12 @@ import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerHeightMirror } from "./height-mirror";
import {
resolveSendTooltipLabel,
resolveSubmitAccessibilityLabel,
resolveVoiceAccessibilityLabel,
resolveVoiceTooltipText,
} from "./labels";
import { computeCanStartDictation } from "./state";
export interface AttachmentMenuItem {
@@ -142,7 +149,6 @@ const MIN_INPUT_HEIGHT_DESKTOP = 46;
const DEFAULT_MAX_INPUT_HEIGHT = 160;
const MAX_INPUT_VIEWPORT_RATIO = 0.5;
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
const ATTACHMENT_SHEET_HEADER: SheetHeader = { title: "Add attachment" };
const ATTACHMENT_SHEET_SNAP_POINTS = ["34%", "45%"];
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
@@ -259,18 +265,24 @@ function AttachmentDropdown({
attachButtonStyle,
renderAttachButtonIcon,
attachmentMenuItems,
addAttachmentLabel,
}: {
isConnected: boolean;
disabled: boolean;
attachButtonStyle: React.ComponentProps<typeof DropdownMenuTrigger>["style"];
renderAttachButtonIcon: (input: { hovered?: boolean }) => React.ReactElement;
attachmentMenuItems: AttachmentMenuItem[];
addAttachmentLabel: string;
}) {
const isCompact = useIsCompactFormFactor();
const [isSheetOpen, setIsSheetOpen] = useState(false);
useDismissKeyboardOnOpen(isSheetOpen, isCompact);
const isButtonDisabled = !isConnected || disabled;
const attachmentSheetHeader = useMemo<SheetHeader>(
() => ({ title: addAttachmentLabel }),
[addAttachmentLabel],
);
const handleOpenSheet = useCallback(() => {
if (isButtonDisabled) return;
setIsSheetOpen(true);
@@ -306,7 +318,7 @@ function AttachmentDropdown({
<>
<Pressable
disabled={isButtonDisabled}
accessibilityLabel="Add attachment"
accessibilityLabel={addAttachmentLabel}
accessibilityRole="button"
testID="message-input-attach-button"
onPress={handleOpenSheet}
@@ -315,7 +327,7 @@ function AttachmentDropdown({
{renderMobileAttachButtonIcon}
</Pressable>
<AdaptiveModalSheet
header={ATTACHMENT_SHEET_HEADER}
header={attachmentSheetHeader}
visible={isSheetOpen}
onClose={handleCloseSheet}
snapPoints={ATTACHMENT_SHEET_SNAP_POINTS}
@@ -333,7 +345,7 @@ function AttachmentDropdown({
<TooltipTrigger asChild>
<DropdownMenuTrigger
disabled={isButtonDisabled}
accessibilityLabel="Add attachment"
accessibilityLabel={addAttachmentLabel}
accessibilityRole="button"
testID="message-input-attach-button"
style={attachButtonStyle}
@@ -342,7 +354,7 @@ function AttachmentDropdown({
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Add attachment</Text>
<Text style={styles.tooltipText}>{addAttachmentLabel}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent
@@ -429,49 +441,6 @@ function SendButtonContent({
return <ThemedArrowUp size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
}
function resolveSubmitAccessibilityLabel(input: {
submitButtonAccessibilityLabel: string | undefined;
canPressLoadingButton: boolean;
defaultActionQueues: boolean;
isAgentRunning: boolean;
}): string {
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
if (input.canPressLoadingButton) return "Interrupt agent";
if (input.defaultActionQueues) return "Queue message";
if (input.isAgentRunning) return "Send and interrupt";
return "Send message";
}
function resolveVoiceAccessibilityLabel(input: {
isRealtimeVoiceForCurrentAgent: boolean;
isMuted: boolean;
isDictating: boolean;
}): string {
if (input.isRealtimeVoiceForCurrentAgent) {
return input.isMuted ? "Unmute Voice mode" : "Mute Voice mode";
}
if (input.isDictating) return "Stop dictation";
return "Start dictation";
}
function resolveVoiceTooltipText(input: {
isRealtimeVoiceForCurrentAgent: boolean;
isMuted: boolean;
}): string {
if (input.isRealtimeVoiceForCurrentAgent) {
return input.isMuted ? "Unmute voice" : "Mute voice";
}
return "Dictation";
}
function resolveSendTooltipLabel(input: {
submitButtonAccessibilityLabel: string | undefined;
defaultActionQueues: boolean;
}): string {
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
return input.defaultActionQueues ? "Queue" : "Send";
}
interface DesktopKeyPressContext {
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined;
submitOnEnter: boolean;
@@ -749,14 +718,16 @@ function MessageInputOverlay({
function FocusHint({
visible,
focusInputKeys,
label,
}: {
visible: boolean;
focusInputKeys: ShortcutChord | null | undefined;
label: string;
}) {
if (!visible || !focusInputKeys) return null;
if (!visible || !focusInputKeys || !label.trim()) return null;
return (
<Text style={styles.focusHintText} pointerEvents="none">
{formatShortcut(focusInputKeys[0], getShortcutOs())} to focus
{label}
</Text>
);
}
@@ -812,9 +783,8 @@ function SendButtonTooltip({
isSubmitLoading,
submitIcon,
buttonIconSize,
submitButtonAccessibilityLabel,
defaultActionQueues,
sendKeys,
sendTooltipLabel,
}: {
shouldShow: boolean;
canPressLoadingButton: boolean;
@@ -826,9 +796,8 @@ function SendButtonTooltip({
isSubmitLoading: boolean;
submitIcon: "arrow" | "return";
buttonIconSize: number;
submitButtonAccessibilityLabel: string | undefined;
defaultActionQueues: boolean;
sendKeys: ShortcutChord | null | undefined;
sendTooltipLabel: string;
}) {
if (!shouldShow) return null;
return (
@@ -847,10 +816,7 @@ function SendButtonTooltip({
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<SendTooltipBody
label={resolveSendTooltipLabel({ submitButtonAccessibilityLabel, defaultActionQueues })}
sendKeys={sendKeys}
/>
<SendTooltipBody label={sendTooltipLabel} sendKeys={sendKeys} />
</TooltipContent>
</Tooltip>
);
@@ -908,6 +874,7 @@ interface ToggleRealtimeVoiceContext {
isAgentRunning: boolean;
handleStopRealtimeVoice: () => Promise<unknown> | void;
toast: { error: (msg: string) => void };
interruptBeforeVoiceMessage: string;
}
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
@@ -920,7 +887,7 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
return;
}
if (ctx.isAgentRunning) {
ctx.toast.error("Interrupt the agent before starting voice mode");
ctx.toast.error(ctx.interruptBeforeVoiceMessage);
return;
}
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
@@ -1199,7 +1166,7 @@ interface ResolvedMessageInputProps {
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
client: DaemonClient | null;
isReadyForDictation: boolean | undefined;
placeholder: string;
placeholder: string | undefined;
autoFocus: boolean;
autoFocusKey: string | undefined;
disabled: boolean;
@@ -1239,7 +1206,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
onAddImages: props.onAddImages,
client: props.client,
isReadyForDictation: props.isReadyForDictation,
placeholder: props.placeholder ?? "Message...",
placeholder: props.placeholder,
autoFocus: props.autoFocus ?? false,
autoFocusKey: props.autoFocusKey,
disabled: props.disabled ?? false,
@@ -1308,6 +1275,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
inputWrapperStyle,
attachmentSlot,
} = resolveMessageInputProps(props);
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const { height: windowHeight } = useWindowDimensions();
const maxInputHeight = resolveMaxInputHeight(windowHeight);
@@ -1564,12 +1532,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isAgentRunning,
handleStopRealtimeVoice,
toast,
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
});
}, [
disabled,
handleStopRealtimeVoice,
isAgentRunning,
isConnected,
t,
toast,
voice,
voiceAgentId,
@@ -1747,17 +1717,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton,
defaultActionQueues,
isAgentRunning,
t,
});
const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent,
isMuted: Boolean(voice?.isMuted),
isDictating,
t,
});
const voiceTooltipText = resolveVoiceTooltipText({
isRealtimeVoiceForCurrentAgent,
isMuted: Boolean(voice?.isMuted),
t,
});
const sendTooltipLabel = resolveSendTooltipLabel({
submitButtonAccessibilityLabel,
defaultActionQueues,
t,
});
const handleInputChange = useCallback(
@@ -1854,9 +1833,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
ref={textInputRef}
value={value}
onChangeText={handleInputChange}
placeholder={placeholder}
placeholder={placeholder ?? t("composer.placeholders.fallback")}
uniProps={textInputPlaceholderColorMapping}
accessibilityLabel="Message agent..."
accessibilityLabel={t("composer.input.accessibilityLabel")}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
style={textInputStyle}
@@ -1872,6 +1851,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
<FocusHint
visible={isWeb && isPaneFocused && !isInputFocused && !value}
focusInputKeys={focusInputKeys}
label={t("composer.input.focusHint", {
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
})}
/>
</View>
@@ -1885,6 +1867,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
attachButtonStyle={attachButtonStyle}
renderAttachButtonIcon={renderAttachButtonIcon}
attachmentMenuItems={attachmentMenuItems}
addAttachmentLabel={t("composer.input.addAttachment")}
/>
{leftContent}
</View>
@@ -1915,9 +1898,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
buttonIconSize={buttonIconSize}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
defaultActionQueues={defaultActionQueues}
sendKeys={sendKeys}
sendTooltipLabel={sendTooltipLabel}
/>
</View>
</View>

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import {
resolveSendTooltipLabel,
resolveSubmitAccessibilityLabel,
resolveVoiceAccessibilityLabel,
resolveVoiceTooltipText,
} from "./labels";
const translations: Record<string, string> = {
"composer.input.interruptAgent": "Interrupt agent",
"composer.input.queueMessage": "Queue message",
"composer.input.sendAndInterrupt": "Send and interrupt",
"composer.input.sendMessage": "Send message",
"composer.input.queue": "Queue",
"composer.input.send": "Send",
"composer.voice.unmuteVoiceMode": "Unmute Voice mode",
"composer.voice.muteVoiceMode": "Mute Voice mode",
"composer.voice.stopDictation": "Stop dictation",
"composer.voice.startDictation": "Start dictation",
"composer.voice.unmuteVoice": "Unmute voice",
"composer.voice.muteVoice": "Mute voice",
"composer.voice.dictation": "Dictation",
};
const t = ((key: string) => translations[key] ?? key) as never;
describe("composer input labels", () => {
it("resolves submit accessibility labels from translations", () => {
expect(
resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel: undefined,
canPressLoadingButton: true,
defaultActionQueues: false,
isAgentRunning: true,
t,
}),
).toBe("Interrupt agent");
expect(
resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel: undefined,
canPressLoadingButton: false,
defaultActionQueues: true,
isAgentRunning: true,
t,
}),
).toBe("Queue message");
expect(
resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel: undefined,
canPressLoadingButton: false,
defaultActionQueues: false,
isAgentRunning: true,
t,
}),
).toBe("Send and interrupt");
expect(
resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel: undefined,
canPressLoadingButton: false,
defaultActionQueues: false,
isAgentRunning: false,
t,
}),
).toBe("Send message");
});
it("keeps explicit submit labels untouched", () => {
expect(
resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel: "Run now",
canPressLoadingButton: false,
defaultActionQueues: false,
isAgentRunning: false,
t,
}),
).toBe("Run now");
});
it("resolves voice labels from translations", () => {
expect(
resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent: true,
isMuted: true,
isDictating: false,
t,
}),
).toBe("Unmute Voice mode");
expect(
resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent: true,
isMuted: false,
isDictating: false,
t,
}),
).toBe("Mute Voice mode");
expect(
resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent: false,
isMuted: false,
isDictating: true,
t,
}),
).toBe("Stop dictation");
expect(
resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent: false,
isMuted: false,
isDictating: false,
t,
}),
).toBe("Start dictation");
});
it("resolves tooltip labels from translations", () => {
expect(
resolveVoiceTooltipText({
isRealtimeVoiceForCurrentAgent: false,
isMuted: false,
t,
}),
).toBe("Dictation");
expect(
resolveSendTooltipLabel({
submitButtonAccessibilityLabel: undefined,
defaultActionQueues: true,
t,
}),
).toBe("Queue");
expect(
resolveSendTooltipLabel({
submitButtonAccessibilityLabel: undefined,
defaultActionQueues: false,
t,
}),
).toBe("Send");
});
});

View File

@@ -0,0 +1,54 @@
import type { TFunction } from "i18next";
export function resolveSubmitAccessibilityLabel(input: {
submitButtonAccessibilityLabel: string | undefined;
canPressLoadingButton: boolean;
defaultActionQueues: boolean;
isAgentRunning: boolean;
t: TFunction;
}): string {
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
if (input.canPressLoadingButton) return input.t("composer.input.interruptAgent");
if (input.defaultActionQueues) return input.t("composer.input.queueMessage");
if (input.isAgentRunning) return input.t("composer.input.sendAndInterrupt");
return input.t("composer.input.sendMessage");
}
export function resolveVoiceAccessibilityLabel(input: {
isRealtimeVoiceForCurrentAgent: boolean;
isMuted: boolean;
isDictating: boolean;
t: TFunction;
}): string {
if (input.isRealtimeVoiceForCurrentAgent) {
return input.isMuted
? input.t("composer.voice.unmuteVoiceMode")
: input.t("composer.voice.muteVoiceMode");
}
if (input.isDictating) return input.t("composer.voice.stopDictation");
return input.t("composer.voice.startDictation");
}
export function resolveVoiceTooltipText(input: {
isRealtimeVoiceForCurrentAgent: boolean;
isMuted: boolean;
t: TFunction;
}): string {
if (input.isRealtimeVoiceForCurrentAgent) {
return input.isMuted
? input.t("composer.voice.unmuteVoice")
: input.t("composer.voice.muteVoice");
}
return input.t("composer.voice.dictation");
}
export function resolveSendTooltipLabel(input: {
submitButtonAccessibilityLabel: string | undefined;
defaultActionQueues: boolean;
t: TFunction;
}): string {
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
return input.defaultActionQueues
? input.t("composer.input.queue")
: input.t("composer.input.send");
}

View File

@@ -1,3 +1,5 @@
import { i18n } from "@/i18n/i18next";
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
export interface AgentInputSubmitActionInput<TAttachment> {
@@ -17,6 +19,7 @@ export interface AgentInputSubmitActionInput<TAttachment> {
setSendError: (message: string | null) => void;
setIsProcessing: (isProcessing: boolean) => void;
onSubmitError?: (error: unknown) => void;
failedToSendMessage?: string;
}
export async function submitAgentInput<TAttachment>(
@@ -67,7 +70,11 @@ export async function submitAgentInput<TAttachment>(
input.setUserInput(trimmedMessage);
input.setAttachments(attachments);
}
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
input.setSendError(
error instanceof Error
? error.message
: (input.failedToSendMessage ?? i18n.t("composer.errors.failedToSend")),
);
input.setIsProcessing(false);
return "failed";
}

View File

@@ -2,6 +2,7 @@ import { useRef, ReactNode, useCallback, useEffect } from "react";
import { Buffer } from "buffer";
import { AppState } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
@@ -440,6 +441,7 @@ export function SessionProvider(props: SessionProviderProps) {
}
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
const { t } = useTranslation();
const voiceRuntime = useVoiceRuntimeOptional();
const voiceAudioEngine = useVoiceAudioEngineOptional();
const queryClient = useQueryClient();
@@ -908,25 +910,25 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId,
setVoiceMode: async (enabled, agentId) => {
if (!client) {
throw new Error("Daemon unavailable");
throw new Error(t("common.errors.daemonUnavailable"));
}
await client.setVoiceMode(enabled, agentId);
},
sendVoiceAudioChunk: async (audioData, mimeType) => {
if (!client) {
throw new Error("Daemon unavailable");
throw new Error(t("common.errors.daemonUnavailable"));
}
await client.sendVoiceAudioChunk(audioData, mimeType);
},
audioPlayed: async (chunkId) => {
if (!client) {
throw new Error("Daemon unavailable");
throw new Error(t("common.errors.daemonUnavailable"));
}
await client.audioPlayed(chunkId);
},
abortRequest: async () => {
if (!client) {
throw new Error("Daemon unavailable");
throw new Error(t("common.errors.daemonUnavailable"));
}
await client.abortRequest();
},
@@ -935,7 +937,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
},
});
return () => unregister?.();
}, [client, serverId, setIsPlayingAudio, voiceRuntime]);
}, [client, serverId, setIsPlayingAudio, t, voiceRuntime]);
useEffect(() => {
voiceRuntime?.updateSessionConnection(serverId, isConnected);

View File

@@ -10,6 +10,12 @@ export interface DesktopPermissionRowProps {
title: string;
status: DesktopPermissionStatus | null;
isRequesting: boolean;
labels: {
granted: string;
request: string;
requesting: string;
busyExtraAction: (label: string) => string;
};
showBorder?: boolean;
onRequest: () => void;
extraActionLabel?: string;
@@ -22,6 +28,7 @@ export function DesktopPermissionRow({
title,
status,
isRequesting,
labels,
showBorder,
onRequest,
extraActionLabel,
@@ -54,7 +61,7 @@ export function DesktopPermissionRow({
<View style={styles.permissionGrantedActions}>
<View style={styles.permissionStatusPill}>
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.permissionStatusText}>Granted</Text>
<Text style={styles.permissionStatusText}>{labels.granted}</Text>
</View>
{extraActionLabel && onExtraAction ? (
<Button
@@ -63,13 +70,13 @@ export function DesktopPermissionRow({
onPress={onExtraAction}
disabled={isExtraActionDisabled || isExtraActionBusy}
>
{isExtraActionBusy ? `${extraActionLabel}...` : extraActionLabel}
{isExtraActionBusy ? labels.busyExtraAction(extraActionLabel) : extraActionLabel}
</Button>
) : null}
</View>
) : (
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
{isRequesting ? "Requesting..." : "Request"}
{isRequesting ? labels.requesting : labels.request}
</Button>
)}
{shouldShowDetail ? (

View File

@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { RotateCw } from "lucide-react-native";
@@ -9,6 +10,7 @@ import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
export function DesktopPermissionsSection() {
const { t } = useTranslation();
const { theme } = useUnistyles();
const {
isDesktopApp,
@@ -59,12 +61,22 @@ export function DesktopPermissionsSection() {
leftIcon={refreshIcon}
onPress={handleRefreshPress}
disabled={isBusy}
accessibilityLabel="Refresh desktop permissions"
accessibilityLabel={t("settings.permissions.refreshAccessibility")}
>
{isRefreshing ? "Refreshing..." : "Refresh"}
{isRefreshing ? t("settings.permissions.refreshing") : t("settings.permissions.refresh")}
</Button>
),
[refreshIcon, handleRefreshPress, isBusy, isRefreshing],
[refreshIcon, handleRefreshPress, isBusy, isRefreshing, t],
);
const permissionLabels = useMemo(
() => ({
granted: t("settings.permissions.actions.granted"),
request: t("settings.permissions.actions.request"),
requesting: t("settings.permissions.actions.requesting"),
busyExtraAction: (label: string) => t("settings.permissions.actions.busySuffix", { label }),
}),
[t],
);
if (!isDesktopApp) {
@@ -72,25 +84,27 @@ export function DesktopPermissionsSection() {
}
return (
<SettingsSection title="Permissions" trailing={refreshButton}>
<SettingsSection title={t("settings.permissions.title")} trailing={refreshButton}>
<View style={settingsStyles.card}>
<DesktopPermissionRow
title="Notifications"
title={t("settings.permissions.notifications")}
status={snapshot?.notifications ?? null}
isRequesting={requestingPermission === "notifications"}
onRequest={handleRequestNotifications}
extraActionLabel="Test"
labels={permissionLabels}
extraActionLabel={t("settings.permissions.test")}
isExtraActionBusy={isSendingTestNotification}
isExtraActionDisabled={!notificationsGranted || isBusy}
onExtraAction={handleSendTestNotification}
/>
{testNotificationError ? <Text style={errorTextStyle}>{testNotificationError}</Text> : null}
<DesktopPermissionRow
title="Microphone"
title={t("settings.permissions.microphone")}
showBorder
status={snapshot?.microphone ?? null}
isRequesting={requestingPermission === "microphone"}
onRequest={handleRequestMicrophone}
labels={permissionLabels}
/>
</View>
</SettingsSection>

View File

@@ -1,6 +1,7 @@
import React, { type ReactElement, useCallback, useMemo, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
@@ -41,6 +42,7 @@ function useKeepRunningAfterQuitToggle(args: {
}
function useDaemonCliStatusModal() {
const { t } = useTranslation();
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
@@ -52,12 +54,12 @@ function useDaemonCliStatusModal() {
setIsCliStatusModalOpen(true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setCliStatusOutput(`Failed to fetch daemon status: ${message}`);
setCliStatusOutput(t("desktop.daemon.fullStatus.fetchFailed", { message }));
setIsCliStatusModalOpen(true);
} finally {
setIsLoadingCliStatus(false);
}
}, []);
}, [t]);
const handleCopyCliStatus = useCallback(() => {
if (!cliStatusOutput) {
@@ -65,13 +67,13 @@ function useDaemonCliStatusModal() {
}
void Clipboard.setStringAsync(cliStatusOutput)
.then(() => {
Alert.alert("Copied", "Status copied to clipboard.");
Alert.alert(t("common.states.copied"), t("desktop.daemon.fullStatus.copied"));
return;
})
.catch((error) => {
console.error("[Settings] Failed to copy daemon status", error);
});
}, [cliStatusOutput]);
}, [cliStatusOutput, t]);
const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []);
@@ -86,6 +88,7 @@ function useDaemonCliStatusModal() {
}
function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) {
const { t } = useTranslation();
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const handleCopyLogPath = useCallback(() => {
@@ -96,14 +99,14 @@ function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) {
void Clipboard.setStringAsync(logPath)
.then(() => {
Alert.alert("Copied", "Log path copied.");
Alert.alert(t("common.states.copied"), t("desktop.daemon.logs.copied"));
return;
})
.catch((error) => {
console.error("[Settings] Failed to copy log path", error);
Alert.alert("Error", "Unable to copy log path.");
Alert.alert(t("common.errors.error"), t("desktop.daemon.logs.copyFailed"));
});
}, [daemonLogs?.logPath]);
}, [daemonLogs?.logPath, t]);
const handleOpenLogs = useCallback(() => {
if (!daemonLogs) {
@@ -124,18 +127,23 @@ interface DaemonLogsModalProps {
}
function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps) {
const { t } = useTranslation();
const header = useMemo<SheetHeader>(() => ({ title: t("desktop.daemon.logs.modalTitle") }), [t]);
return (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
header={DAEMON_LOGS_HEADER}
header={header}
testID="managed-daemon-logs-dialog"
snapPoints={LOGS_MODAL_SNAP_POINTS}
>
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable"}</Text>
<Text style={settingsStyles.rowHint}>
{daemonLogs?.logPath ?? t("desktop.daemon.logs.unavailable")}
</Text>
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
{daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"}
{daemonLogs?.contents?.length ? daemonLogs.contents : t("desktop.daemon.logs.empty")}
</Text>
</View>
</AdaptiveModalSheet>
@@ -155,11 +163,17 @@ function DaemonCliStatusModal({
cliStatusOutput,
onCopy,
}: DaemonCliStatusModalProps) {
const { t } = useTranslation();
const header = useMemo<SheetHeader>(
() => ({ title: t("desktop.daemon.fullStatus.modalTitle") }),
[t],
);
return (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
header={DAEMON_STATUS_HEADER}
header={header}
testID="daemon-cli-status-dialog"
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
>
@@ -169,10 +183,10 @@ function DaemonCliStatusModal({
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={onClose}>
Close
{t("common.actions.close")}
</Button>
<Button size="sm" onPress={onCopy}>
Copy
{t("common.actions.copy")}
</Button>
</View>
</View>
@@ -200,6 +214,7 @@ interface DaemonInfoCardProps {
}
function DaemonInfoCard(props: DaemonInfoCardProps) {
const { t } = useTranslation();
const {
daemonStatusStateText,
daemonStatusDetailText,
@@ -223,8 +238,8 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here</Text>
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.status.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.status.builtInOnly")}</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
@@ -233,39 +248,39 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Manage built-in daemon</Text>
<Text style={settingsStyles.rowHint}>Let Paseo start and stop the built-in daemon</Text>
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.management.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.management.hint")}</Text>
</View>
<Switch
value={!isDaemonManagementPaused}
onValueChange={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
accessibilityLabel="Manage built-in daemon"
accessibilityLabel={t("desktop.daemon.management.title")}
/>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Keep daemon running after quit</Text>
<Text style={settingsStyles.rowHint}>Daemon keeps running when you quit Paseo</Text>
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.keepRunning.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.keepRunning.hint")}</Text>
</View>
<Switch
value={keepRunningAfterQuit}
onValueChange={handleToggleKeepRunningAfterQuit}
disabled={isUpdatingKeepRunningAfterQuit}
accessibilityLabel="Keep daemon running after quit"
accessibilityLabel={t("desktop.daemon.keepRunning.title")}
/>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.logs.title")}</Text>
<Text style={settingsStyles.rowHint}>
{daemonLogs?.logPath ?? "Log path unavailable"}
{daemonLogs?.logPath ?? t("desktop.daemon.logs.unavailable")}
</Text>
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
<Button variant="outline" size="sm" leftIcon={copyIcon} onPress={handleCopyLogPath}>
Copy path
{t("desktop.daemon.logs.copyPath")}
</Button>
) : null}
<Button
@@ -275,16 +290,14 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
onPress={handleOpenLogs}
disabled={!daemonLogs}
>
Open logs
{t("desktop.daemon.logs.open")}
</Button>
</View>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output
</Text>
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.fullStatus.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.fullStatus.hint")}</Text>
</View>
<Button
variant="outline"
@@ -293,7 +306,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
onPress={handleRunCliStatus}
disabled={isLoadingCliStatus}
>
{isLoadingCliStatus ? "Loading..." : "View status"}
{isLoadingCliStatus ? t("common.states.loading") : t("desktop.daemon.fullStatus.view")}
</Button>
</View>
</View>
@@ -301,6 +314,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
}
export function LocalDaemonSection() {
const { t } = useTranslation();
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const appVersion = resolveAppVersion();
@@ -318,8 +332,13 @@ export function LocalDaemonSection() {
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
const daemonStatusStateText =
statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running");
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`;
statusError ??
(daemonStatus?.status === "running"
? t("desktop.daemon.status.running")
: t("desktop.daemon.status.notRunning"));
const daemonStatusDetailText = t("desktop.daemon.status.pid", {
pid: daemonStatus?.pid ? daemonStatus.pid : "—",
});
const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon;
const { isUpdating: isUpdatingDaemonManagement, toggle: handleToggleDaemonManagement } =
@@ -382,12 +401,12 @@ export function LocalDaemonSection() {
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenAdvancedSettings}
accessibilityLabel="Open advanced daemon settings"
accessibilityLabel={t("desktop.daemon.openAdvancedSettings")}
>
Advanced settings
{t("desktop.daemon.advancedSettings")}
</Button>
),
[advancedSettingsIcon, handleOpenAdvancedSettings],
[advancedSettingsIcon, handleOpenAdvancedSettings, t],
);
if (!showSection) {
@@ -396,7 +415,7 @@ export function LocalDaemonSection() {
return (
<SettingsSection
title="Daemon"
title={t("desktop.daemon.title")}
trailing={advancedSettingsButton}
testID="host-page-daemon-lifecycle-card"
>
@@ -427,11 +446,7 @@ export function LocalDaemonSection() {
{daemonVersionMismatch ? (
<View style={styles.warningCard}>
<Text style={styles.warningText}>
{
"App and daemon versions don't match. Update both to the same version for the best experience."
}
</Text>
<Text style={styles.warningText}>{t("desktop.daemon.versionMismatch")}</Text>
</View>
) : null}
</>
@@ -513,5 +528,3 @@ const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard];
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"];
const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"];
const DAEMON_LOGS_HEADER: SheetHeader = { title: "Daemon logs" };
const DAEMON_STATUS_HEADER: SheetHeader = { title: "Daemon status" };

View File

@@ -1,4 +1,6 @@
import { useCallback, useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -8,16 +10,34 @@ import { SettingsSection } from "@/screens/settings/settings-section";
import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url";
import { confirmDialog } from "@/utils/confirm-dialog";
import { shouldUseDesktopDaemon, type SkillsStatus } from "@/desktop/daemon/desktop-daemon";
import {
shouldUseDesktopDaemon,
type SkillOp,
type SkillsStatus,
} from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const UNINSTALL_MESSAGE =
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
const OP_KIND_LABEL_KEY: Record<SkillOp["kind"], string> = {
add: "settings.integrations.operations.add",
update: "settings.integrations.operations.update",
delete: "settings.integrations.operations.delete",
};
function formatUpdateMessage(ops: readonly SkillOp[], t: TFunction): string {
const sorted = [...ops].sort((a, b) => {
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
});
return sorted.map((op) => `${t(OP_KIND_LABEL_KEY[op.kind])} ${op.name}`).join("\n");
}
export function IntegrationsSection() {
const { t } = useTranslation();
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const {
@@ -56,20 +76,30 @@ export function IntegrationsSection() {
const handleUpdateSkills = useCallback(async () => {
if (isSkillsWorking) return;
const ops = skillsStatus?.ops ?? [];
const confirmed = await confirmDialog({
title: t("settings.integrations.skills.updateTitle"),
message:
ops.length > 0
? formatUpdateMessage(ops, t)
: t("settings.integrations.skills.updateFallback"),
confirmLabel: t("settings.integrations.actions.update"),
});
if (!confirmed) return;
await updateSkills();
}, [isSkillsWorking, updateSkills]);
}, [isSkillsWorking, skillsStatus, t, updateSkills]);
const handleUninstallSkills = useCallback(async () => {
if (isSkillsWorking) return;
const confirmed = await confirmDialog({
title: "Uninstall Paseo skills?",
message: UNINSTALL_MESSAGE,
confirmLabel: "Uninstall",
title: t("settings.integrations.skills.uninstallTitle"),
message: t("settings.integrations.skills.uninstallMessage"),
confirmLabel: t("settings.integrations.actions.uninstall"),
destructive: true,
});
if (!confirmed) return;
await uninstallSkills();
}, [isSkillsWorking, uninstallSkills]);
}, [isSkillsWorking, t, uninstallSkills]);
const handleOpenCliDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL);
@@ -94,9 +124,9 @@ export function IntegrationsSection() {
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenCliDocs}
accessibilityLabel="Open CLI documentation"
accessibilityLabel={t("settings.integrations.docs.openCli")}
>
CLI docs
{t("settings.integrations.docs.cli")}
</Button>
<Button
variant="ghost"
@@ -105,13 +135,13 @@ export function IntegrationsSection() {
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenSkillsDocs}
accessibilityLabel="Open skills documentation"
accessibilityLabel={t("settings.integrations.docs.openSkills")}
>
Skills docs
{t("settings.integrations.docs.skills")}
</Button>
</View>
),
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs],
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs, t],
);
if (!showSection) {
@@ -121,20 +151,24 @@ export function IntegrationsSection() {
const skillsState = skillsStatus?.state ?? null;
return (
<SettingsSection title="Integrations" trailing={trailing}>
<SettingsSection title={t("settings.integrations.title")} trailing={trailing}>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>Command line</Text>
<Text style={settingsStyles.rowTitle}>
{t("settings.integrations.commandLine.title")}
</Text>
</View>
<Text style={settingsStyles.rowHint}>Control and script agents from your terminal</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.integrations.commandLine.description")}
</Text>
</View>
{cliStatus?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
</View>
) : (
<Button
@@ -143,7 +177,9 @@ export function IntegrationsSection() {
onPress={handleInstallCli}
disabled={isInstallingCli}
>
{isInstallingCli ? "Installing..." : "Install"}
{isInstallingCli
? t("settings.integrations.actions.installing")
: t("settings.integrations.actions.install")}
</Button>
)}
</View>
@@ -151,12 +187,12 @@ export function IntegrationsSection() {
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
<Text style={settingsStyles.rowTitle}>{t("settings.integrations.skills.title")}</Text>
</View>
<Text style={settingsStyles.rowHint}>
{skillsState === "drift"
? "Update available"
: "Teach your agents to orchestrate through the CLI"}
? t("settings.integrations.skills.updateAvailable")
: t("settings.integrations.skills.description")}
</Text>
</View>
<SkillsActions
@@ -181,6 +217,7 @@ interface SkillsActionsProps {
}
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
if (state === "up-to-date") {
@@ -188,10 +225,10 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
<View style={styles.actionsRow}>
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
</View>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
Uninstall
{t("settings.integrations.actions.uninstall")}
</Button>
</View>
);
@@ -201,10 +238,12 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
return (
<View style={styles.actionsRow}>
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
{isWorking ? "Working..." : "Update"}
{isWorking
? t("settings.integrations.actions.working")
: t("settings.integrations.actions.update")}
</Button>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
Uninstall
{t("settings.integrations.actions.uninstall")}
</Button>
</View>
);
@@ -212,7 +251,9 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
return (
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
{isWorking ? "Installing..." : "Install"}
{isWorking
? t("settings.integrations.actions.installing")
: t("settings.integrations.actions.install")}
</Button>
);
}

View File

@@ -1,3 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
@@ -8,12 +10,17 @@ export interface PairDeviceModalProps {
}
const SNAP_POINTS: string[] = ["82%", "94%"];
const PAIR_DEVICE_HEADER: SheetHeader = { title: "Pair a device" };
export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) {
const { t } = useTranslation();
const header = useMemo<SheetHeader>(
() => ({ title: t("settings.integrations.pairDevices.rowTitle") }),
[t],
);
return (
<AdaptiveModalSheet
header={PAIR_DEVICE_HEADER}
header={header}
visible={visible}
onClose={onClose}
snapPoints={SNAP_POINTS}

View File

@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Image, Text, TextInput, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
@@ -21,18 +22,21 @@ function resolvePairingViewState(args: {
isError: boolean;
error: unknown;
data: { url?: string | null; relayEnabled?: boolean } | undefined;
labels: {
failedToLoadOffer: string;
relayDisabled: string;
unavailable: string;
};
}): PairingViewState {
if (args.isPending) return { tag: "loading" };
if (args.isError) {
const message =
args.error instanceof Error ? args.error.message : "Failed to load pairing offer.";
args.error instanceof Error ? args.error.message : args.labels.failedToLoadOffer;
return { tag: "error", message };
}
if (!args.data?.url) {
const message =
args.data?.relayEnabled === false
? "Relay is not enabled. Enable relay to pair a device."
: "Pairing offer unavailable.";
args.data?.relayEnabled === false ? args.labels.relayDisabled : args.labels.unavailable;
return { tag: "unavailable", message };
}
return { tag: "ready", url: args.data.url };
@@ -40,6 +44,7 @@ function resolvePairingViewState(args: {
export function PairDeviceSection() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const showSection = shouldUseDesktopDaemon();
const [copied, setCopied] = useState(false);
@@ -96,6 +101,17 @@ export function PairDeviceSection() {
),
[copied, theme.iconSize.sm, theme.colors.accent, theme.colors.foreground],
);
const bodyLabels = useMemo(
() => ({
loadingOffer: t("pairing.device.loadingOffer"),
hint: t("pairing.device.hint"),
qrUnavailable: t("pairing.device.qrUnavailable"),
retry: t("pairing.device.retry"),
copy: t("pairing.device.copy"),
copied: t("pairing.device.copied"),
}),
[t],
);
if (!showSection) return null;
@@ -104,6 +120,11 @@ export function PairDeviceSection() {
isError: pairingQuery.isError,
error: pairingQuery.error,
data: pairingQuery.data,
labels: {
failedToLoadOffer: t("pairing.device.failedToLoadOffer"),
relayDisabled: t("pairing.device.relayDisabled"),
unavailable: t("pairing.device.unavailable"),
},
});
return (
@@ -119,6 +140,7 @@ export function PairDeviceSection() {
copied={copied}
handleRefetch={handleRefetch}
handleCopyPress={handleCopyPress}
labels={bodyLabels}
/>
</View>
</View>
@@ -135,6 +157,14 @@ interface PairDeviceBodyProps {
copied: boolean;
handleRefetch: () => void;
handleCopyPress: () => void;
labels: {
loadingOffer: string;
hint: string;
qrUnavailable: string;
retry: string;
copy: string;
copied: string;
};
}
function PairDeviceBody(props: PairDeviceBodyProps) {
@@ -148,13 +178,14 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
copied,
handleRefetch,
handleCopyPress,
labels,
} = props;
if (viewState.tag === "loading") {
return (
<View style={styles.centered}>
<ActivityIndicator size="small" />
<Text style={styles.hint}>Loading pairing offer</Text>
<Text style={styles.hint}>{labels.loadingOffer}</Text>
</View>
);
}
@@ -164,7 +195,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
<View style={styles.centered}>
<Text style={styles.hint}>{viewState.message}</Text>
<Button variant="outline" size="sm" leftIcon={retryIcon} onPress={handleRefetch}>
Retry
{labels.retry}
</Button>
</View>
);
@@ -172,11 +203,13 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
return (
<View style={styles.content}>
<Text style={styles.hint}>
Scan this QR code with Paseo on your phone, or copy the link below.
</Text>
<Text style={styles.hint}>{labels.hint}</Text>
<View style={styles.qrContainer}>
<PairDeviceQrContent qrImageSource={qrImageSource} qrQuery={qrQuery} />
<PairDeviceQrContent
qrImageSource={qrImageSource}
qrQuery={qrQuery}
unavailableLabel={labels.qrUnavailable}
/>
</View>
<View style={styles.linkRow}>
<View style={styles.inputWrapper}>
@@ -189,7 +222,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
/>
</View>
<Button variant="outline" size="sm" leftIcon={copyButtonIcon} onPress={handleCopyPress}>
{copied ? "Copied" : "Copy"}
{copied ? labels.copied : labels.copy}
</Button>
</View>
</View>
@@ -199,12 +232,13 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
function PairDeviceQrContent(props: {
qrImageSource: { uri: string } | null;
qrQuery: { isError: boolean };
unavailableLabel: string;
}) {
if (props.qrImageSource) {
return <Image source={props.qrImageSource} style={styles.qrImage} resizeMode="contain" />;
}
if (props.qrQuery.isError) {
return <Text style={styles.hint}>QR code unavailable.</Text>;
return <Text style={styles.hint}>{props.unavailableLabel}</Text>;
}
return <ActivityIndicator size="small" />;
}

View File

@@ -1,3 +1,5 @@
import { i18n } from "@/i18n/i18next";
export class DaemonConnectionRegistrationError extends Error {
constructor(message: string) {
super(message);
@@ -34,19 +36,18 @@ export function getDaemonManagementErrorPresentation(
if (presentationError instanceof DaemonConnectionRegistrationError) {
return {
message:
"Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.",
message: i18n.t("desktop.daemon.management.registrationFailed"),
refreshStatus: true,
};
}
if (wasManagingDaemon) {
return {
message: "Built-in daemon management was paused, but Paseo could not stop the daemon.",
message: i18n.t("desktop.daemon.management.pausedStopFailed"),
refreshStatus: false,
};
}
return {
message: "Unable to update built-in daemon management.",
message: i18n.t("desktop.daemon.management.updateFailed"),
refreshStatus: false,
};
}

View File

@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
type DesktopDaemonStatus,
startDesktopDaemon,
@@ -38,6 +39,7 @@ interface UseBuiltInDaemonManagementResult {
export function useBuiltInDaemonManagement(
input: UseBuiltInDaemonManagementInput,
): UseBuiltInDaemonManagementResult {
const { t } = useTranslation();
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
const reportError = useDesktopIpcErrorReporter();
const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation<
@@ -50,11 +52,10 @@ export function useBuiltInDaemonManagement(
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
confirm: () =>
confirmDialog({
title: "Pause built-in daemon",
message:
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
title: t("desktop.daemon.management.pauseTitle"),
message: t("desktop.daemon.management.pauseMessage"),
confirmLabel: t("desktop.daemon.management.pauseAndStop"),
cancelLabel: t("common.actions.cancel"),
destructive: true,
}),
persistSettings: (next) => updateSettings(next) as Promise<void>,

View File

@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
getDesktopDaemonLogs,
getDesktopDaemonStatus,
@@ -17,6 +18,7 @@ interface DaemonStatusData {
}
export function useDaemonStatus() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const enabled = shouldUseDesktopDaemon();
@@ -33,7 +35,7 @@ export function useDaemonStatus() {
});
useDesktopIpcQueryErrorToast({
error: query.error,
message: "Unable to load desktop daemon status.",
message: t("desktop.daemon.loadFailed"),
logLabel: "[DesktopDaemon] Failed to load daemon status",
});

View File

@@ -5,6 +5,7 @@ import React from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@/i18n/i18next";
import { useCliInstall, useSkillsStatus } from "./use-install-status";
const toast = vi.hoisted(() => ({
@@ -54,6 +55,7 @@ describe("useCliInstall", () => {
});
afterEach(() => {
void i18n.changeLanguage("en");
vi.restoreAllMocks();
vi.clearAllMocks();
});
@@ -89,6 +91,28 @@ describe("useCliInstall", () => {
expect(toast.error).toHaveBeenCalledWith("Unable to install the Paseo CLI.");
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install CLI", error);
});
it("uses the active language for CLI install errors", async () => {
await i18n.changeLanguage("zh-CN");
const error = new Error("Missing IPC handler");
desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: false });
desktopDaemon.installCli.mockRejectedValue(error);
const { result } = renderDesktopHook(() => useCliInstall());
await waitFor(() => {
expect(result.current.status).toEqual({ installed: false });
});
act(() => {
result.current.install();
});
await waitFor(() => {
expect(result.current.error).toBe(error);
});
expect(toast.error).toHaveBeenCalledWith("无法安装 Paseo CLI。");
});
});
describe("useSkillsStatus", () => {
@@ -97,6 +121,7 @@ describe("useSkillsStatus", () => {
});
afterEach(() => {
void i18n.changeLanguage("en");
vi.restoreAllMocks();
vi.clearAllMocks();
});

View File

@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
getCliInstallStatus,
getSkillsStatus,
@@ -29,6 +30,7 @@ interface DesktopInstallHookResult {
}
export function useCliInstall(): DesktopInstallHookResult {
const { t } = useTranslation();
const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
@@ -42,7 +44,7 @@ export function useCliInstall(): DesktopInstallHookResult {
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({
error: statusQuery.error,
message: "Unable to check CLI install status.",
message: t("desktop.integrations.cli.statusFailed"),
logLabel: "[Integrations] Failed to load CLI status",
});
@@ -51,7 +53,7 @@ export function useCliInstall(): DesktopInstallHookResult {
onError: (error) => {
reportError({
error,
message: "Unable to install the Paseo CLI.",
message: t("desktop.integrations.cli.installFailed"),
logLabel: "[Integrations] Failed to install CLI",
});
},
@@ -88,6 +90,7 @@ export interface SkillsStatusHookResult {
}
export function useSkillsStatus(): SkillsStatusHookResult {
const { t } = useTranslation();
const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
@@ -101,7 +104,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
const { data: status, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({
error: statusQuery.error,
message: "Unable to check orchestration skills status.",
message: t("desktop.integrations.skills.statusFailed"),
logLabel: "[Integrations] Failed to load skills status",
});
@@ -117,7 +120,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onError: (error) => {
reportError({
error,
message: "Unable to install orchestration skills.",
message: t("desktop.integrations.skills.installFailed"),
logLabel: "[Integrations] Failed to install skills",
});
},
@@ -129,7 +132,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onError: (error) => {
reportError({
error,
message: "Unable to update orchestration skills.",
message: t("desktop.integrations.skills.updateFailed"),
logLabel: "[Integrations] Failed to update skills",
});
},
@@ -141,7 +144,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onError: (error) => {
reportError({
error,
message: "Unable to uninstall orchestration skills.",
message: t("desktop.integrations.skills.uninstallFailed"),
logLabel: "[Integrations] Failed to uninstall skills",
});
},

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { DesktopHostBridge } from "@/desktop/host";
import { i18n } from "@/i18n/i18next";
import {
createDesktopPermissions,
type DesktopPermissionEnvironment,
@@ -180,4 +181,28 @@ describe("desktop-permissions", () => {
expect(result.state).toBe("denied");
});
it("uses the active app language for local status details", async () => {
await i18n.changeLanguage("zh-CN");
try {
const permissions = createDesktopPermissions(
fakeEnvironment({
notification: { permission: "granted" },
navigator: {
permissions: {
query: vi.fn(async () => ({ state: "prompt" })),
},
mediaDevices: { getUserMedia: vi.fn() },
},
}),
);
const snapshot = await permissions.getDesktopPermissionSnapshot();
expect(snapshot.notifications.detail).toBe("系统已允许通知。");
expect(snapshot.microphone.detail).toBe("麦克风权限尚未授予。");
} finally {
await i18n.changeLanguage("en");
}
});
});

View File

@@ -1,5 +1,6 @@
import { type DesktopHostBridge, getDesktopHost } from "@/desktop/host";
import { isNative, isWeb } from "@/constants/platform";
import { i18n } from "@/i18n/i18next";
export type DesktopPermissionKind = "notifications" | "microphone";
@@ -97,24 +98,26 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
if (permission === "granted") {
return status({
state: "granted",
detail: "Notifications are allowed by the OS.",
detail: i18n.t("desktop.permissions.notifications.allowed"),
});
}
if (permission === "denied") {
return status({
state: "denied",
detail: "Notifications are denied in system settings.",
detail: i18n.t("desktop.permissions.notifications.denied"),
});
}
if (permission === "default") {
return status({
state: "prompt",
detail: "Notifications have not been granted yet.",
detail: i18n.t("desktop.permissions.notifications.notGranted"),
});
}
return status({
state: "unknown",
detail: `Unexpected notification permission state: ${permission}`,
detail: i18n.t("desktop.permissions.notifications.unexpectedState", {
state: permission,
}),
});
}
@@ -127,7 +130,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!env.isWeb) {
return status({
state: "unavailable",
detail: "Desktop notification status is only available on web runtime.",
detail: i18n.t("desktop.permissions.notifications.webOnly"),
});
}
@@ -138,8 +141,8 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
return status({
state: supported ? "granted" : "unavailable",
detail: supported
? "Desktop notifications are supported."
: "Desktop notifications are not supported on this platform.",
? i18n.t("desktop.permissions.notifications.supported")
: i18n.t("desktop.permissions.notifications.unsupported"),
});
} catch {
// Fall through to web API check
@@ -153,7 +156,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
return status({
state: "unavailable",
detail: "Web Notification API is unavailable in this environment.",
detail: i18n.t("desktop.permissions.notifications.apiUnavailable"),
});
}
@@ -161,7 +164,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!env.isWeb) {
return status({
state: "unavailable",
detail: "Desktop microphone status is only available on web runtime.",
detail: i18n.t("desktop.permissions.microphone.webOnly"),
});
}
@@ -169,7 +172,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!webNavigator) {
return status({
state: "unavailable",
detail: "Navigator is unavailable in this environment.",
detail: i18n.t("desktop.permissions.microphone.navigatorUnavailable"),
});
}
@@ -180,36 +183,39 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (result?.state === "granted") {
return status({
state: "granted",
detail: "Microphone access is granted.",
detail: i18n.t("desktop.permissions.microphone.granted"),
});
}
if (result?.state === "denied") {
return status({
state: "denied",
detail: "Microphone access is denied in system settings.",
detail: i18n.t("desktop.permissions.microphone.denied"),
});
}
if (result?.state === "prompt") {
return status({
state: "prompt",
detail: "Microphone permission has not been granted yet.",
detail: i18n.t("desktop.permissions.microphone.notGranted"),
});
}
return status({
state: "unknown",
detail: `Unexpected microphone permission state: ${result?.state ?? "unknown"}`,
detail: i18n.t("desktop.permissions.microphone.unexpectedState", {
state: result?.state ?? "unknown",
}),
});
} catch (error) {
if (isPermissionsQueryRuntimeUnsupported(error)) {
return status({
state: "unknown",
detail:
"Microphone status API is unavailable in this runtime. Use Request to check access.",
detail: i18n.t("desktop.permissions.microphone.statusApiUnavailable"),
});
}
return status({
state: "unknown",
detail: `Failed to query microphone status: ${getErrorMessage(error)}`,
detail: i18n.t("desktop.permissions.microphone.queryFailed", {
message: getErrorMessage(error),
}),
});
}
}
@@ -217,13 +223,13 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
return status({
state: "unavailable",
detail: "Microphone capture is unavailable in this environment.",
detail: i18n.t("desktop.permissions.microphone.captureUnavailable"),
});
}
return status({
state: "unknown",
detail: "Permission status API is unavailable. Use Request to check access.",
detail: i18n.t("desktop.permissions.microphone.permissionApiUnavailable"),
});
}
@@ -231,7 +237,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!env.isWeb) {
return status({
state: "unavailable",
detail: "Desktop notification requests are only available on web runtime.",
detail: i18n.t("desktop.permissions.notifications.requestsWebOnly"),
});
}
@@ -246,14 +252,16 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
} catch (error) {
return status({
state: "unknown",
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
detail: i18n.t("desktop.permissions.notifications.requestFailed", {
message: getErrorMessage(error),
}),
});
}
}
return status({
state: "unavailable",
detail: "Web Notification API requestPermission() is unavailable.",
detail: i18n.t("desktop.permissions.notifications.requestUnavailable"),
});
}
@@ -261,7 +269,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!env.isWeb) {
return status({
state: "unavailable",
detail: "Desktop microphone requests are only available on web runtime.",
detail: i18n.t("desktop.permissions.microphone.requestsWebOnly"),
});
}
@@ -269,7 +277,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
return status({
state: "unavailable",
detail: "Microphone capture API is unavailable in this environment.",
detail: i18n.t("desktop.permissions.microphone.captureApiUnavailable"),
});
}
@@ -287,18 +295,20 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (errorName === "NotAllowedError" || errorName === "PermissionDeniedError") {
return status({
state: "denied",
detail: "Microphone permission was denied by the user or system.",
detail: i18n.t("desktop.permissions.microphone.requestDenied"),
});
}
if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") {
return status({
state: "unavailable",
detail: "No microphone device was found.",
detail: i18n.t("desktop.permissions.microphone.noDevice"),
});
}
return status({
state: "unknown",
detail: `Failed to request microphone permission: ${getErrorMessage(error)}`,
detail: i18n.t("desktop.permissions.microphone.requestFailed", {
message: getErrorMessage(error),
}),
});
}
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
getDesktopPermissionSnapshot,
requestDesktopPermission,
@@ -20,17 +21,8 @@ export interface UseDesktopPermissionsReturn {
sendTestNotification: () => Promise<void>;
}
const EMPTY_NOTIFICATION_STATUS = {
state: "unknown" as const,
detail: "Notification status has not been checked yet.",
};
const EMPTY_MICROPHONE_STATUS = {
state: "unknown" as const,
detail: "Microphone status has not been checked yet.",
};
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const { t } = useTranslation();
const isDesktopApp = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true);
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
@@ -83,8 +75,14 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
setSnapshot((previous) => {
const base: DesktopPermissionSnapshot = previous ?? {
checkedAt: Date.now(),
notifications: EMPTY_NOTIFICATION_STATUS,
microphone: EMPTY_MICROPHONE_STATUS,
notifications: {
state: "unknown",
detail: t("desktop.permissions.empty.notifications"),
},
microphone: {
state: "unknown",
detail: t("desktop.permissions.empty.microphone"),
},
};
if (kind === "notifications") {
@@ -110,7 +108,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
await refreshPermissions();
}
},
[isDesktopApp, refreshPermissions],
[isDesktopApp, refreshPermissions, t],
);
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
@@ -124,22 +122,20 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
setTestNotificationError(null);
try {
const sent = await sendOsNotification({
title: "Paseo notification test",
body: "If you can see this, desktop notifications work.",
title: t("desktop.permissions.testNotification.title"),
body: t("desktop.permissions.testNotification.body"),
});
if (!sent) {
setTestNotificationError(
"Notification was not delivered. Check System Settings > Notifications.",
);
setTestNotificationError(t("desktop.permissions.testNotification.notDelivered"));
}
} catch {
setTestNotificationError("Failed to send notification.");
setTestNotificationError(t("desktop.permissions.testNotification.failed"));
} finally {
if (isMountedRef.current) {
setIsSendingTestNotification(false);
}
}
}, [isDesktopApp]);
}, [isDesktopApp, t]);
useEffect(() => {
if (!isDesktopApp) {

Some files were not shown because too many files have changed in this diff Show More