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
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.
+99 -4
View File
@@ -2567,9 +2567,9 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.28.6", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -20529,6 +20529,19 @@
"react-native": "*" "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": { "node_modules/expo-manifests": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz", "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz",
@@ -23838,6 +23851,15 @@
"node": ">=12" "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": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "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==", "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause" "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": { "node_modules/iconv-corefoundation": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
@@ -31270,6 +31320,33 @@
"react": ">=17.0.0" "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": { "node_modules/react-is": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
@@ -32650,6 +32727,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -35233,7 +35316,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -36118,6 +36201,15 @@
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
"license": "MIT" "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": { "node_modules/w3c-keyname": {
"version": "2.2.8", "version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
@@ -37026,6 +37118,7 @@
"expo-image-picker": "^17.0.8", "expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7", "expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-localization": "~17.0.9",
"expo-notifications": "^0.32.16", "expo-notifications": "^0.32.16",
"expo-router": "~6.0.13", "expo-router": "~6.0.13",
"expo-sharing": "^14.0.8", "expo-sharing": "^14.0.8",
@@ -37033,12 +37126,14 @@
"expo-system-ui": "~6.0.7", "expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12", "expo-updates": "~29.0.12",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"i18next": "^26.3.0",
"lucide-react-native": "^0.546.0", "lucide-react-native": "^0.546.0",
"markdown-it": "^10.0.0", "markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7", "mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-i18next": "^17.0.8",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3", "react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0", "react-native-edge-to-edge": "^1.7.0",
+2 -2
View File
@@ -34,7 +34,7 @@ export async function editWorktreeSetup(page: Page, setupCommands: string[]): Pr
} }
export async function clickSaveProjectSettings(page: Page): Promise<void> { 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> { 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> { 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 --- // --- Form-state assertions ---
+23
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();
});
+3
View File
@@ -73,6 +73,7 @@
"expo-image-picker": "^17.0.8", "expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7", "expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-localization": "~17.0.9",
"expo-notifications": "^0.32.16", "expo-notifications": "^0.32.16",
"expo-router": "~6.0.13", "expo-router": "~6.0.13",
"expo-sharing": "^14.0.8", "expo-sharing": "^14.0.8",
@@ -80,12 +81,14 @@
"expo-system-ui": "~6.0.7", "expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12", "expo-updates": "~29.0.12",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"i18next": "^26.3.0",
"lucide-react-native": "^0.546.0", "lucide-react-native": "^0.546.0",
"markdown-it": "^10.0.0", "markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7", "mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-i18next": "^17.0.8",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3", "react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0", "react-native-edge-to-edge": "^1.7.0",
+24 -11
View File
@@ -10,6 +10,7 @@ import React, {
type ComponentProps, type ComponentProps,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { import {
View, View,
Text, Text,
@@ -157,6 +158,7 @@ function renderStreamItemWithTurnFooter(input: {
function renderListEmptyComponent(input: { function renderListEmptyComponent(input: {
renderModel: AgentStreamRenderModel; renderModel: AgentStreamRenderModel;
emptyStateStyle: StyleProp<ViewStyle>; emptyStateStyle: StyleProp<ViewStyle>;
emptyText: string;
}): ReactNode { }): ReactNode {
if ( if (
input.renderModel.boundary.hasVirtualizedHistory || input.renderModel.boundary.hasVirtualizedHistory ||
@@ -170,7 +172,7 @@ function renderListEmptyComponent(input: {
return ( return (
<View style={input.emptyStateStyle}> <View style={input.emptyStateStyle}>
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text> <Text style={stylesheet.emptyStateText}>{input.emptyText}</Text>
</View> </View>
); );
} }
@@ -245,6 +247,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, },
ref, ref,
) { ) {
const { t } = useTranslation();
const viewportRef = useRef<StreamViewportHandle | null>(null); const viewportRef = useRef<StreamViewportHandle | null>(null);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo( const streamRenderStrategy = useMemo(
@@ -637,8 +640,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []); const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const listEmptyComponent = useMemo( const listEmptyComponent = useMemo(
() => renderListEmptyComponent({ renderModel, emptyStateStyle }), () =>
[renderModel, emptyStateStyle], renderListEmptyComponent({
renderModel,
emptyStateStyle,
emptyText: t("agentStream.empty"),
}),
[renderModel, emptyStateStyle, t],
); );
const { boundary, auxiliary } = renderModel; const { boundary, auxiliary } = renderModel;
@@ -745,7 +753,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
style={stylesheet.scrollToBottomButton} style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom} onPress={scrollToBottom}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Scroll to bottom" accessibilityLabel={t("agentStream.scrollToBottom")}
testID="scroll-to-bottom-button" testID="scroll-to-bottom-button"
> >
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} /> <ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
@@ -906,11 +914,14 @@ function PermissionRequestCard({
permission: PendingPermission; permission: PendingPermission;
client: DaemonClient | null; client: DaemonClient | null;
}) { }) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const { request } = permission; const { request } = permission;
const isPlanRequest = request.kind === "plan"; 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 description = request.description ?? "";
const resolvedToolCallDetail = useMemo( const resolvedToolCallDetail = useMemo(
() => () =>
@@ -931,19 +942,21 @@ function PermissionRequestCard({
return [ return [
{ {
id: "reject", id: "reject",
label: "Deny", label: t("agentStream.permission.deny"),
behavior: "deny", behavior: "deny",
variant: "danger", variant: "danger",
intent: "dismiss", intent: "dismiss",
}, },
{ {
id: "accept", id: "accept",
label: isPlanRequest ? "Implement" : "Accept", label: isPlanRequest
? t("agentStream.permission.implement")
: t("agentStream.permission.accept"),
behavior: "allow", behavior: "allow",
variant: "primary", variant: "primary",
}, },
]; ];
}, [isPlanRequest, request]); }, [isPlanRequest, request, t]);
const planMarkdown = useMemo(() => { const planMarkdown = useMemo(() => {
if (!request) { if (!request) {
@@ -968,7 +981,7 @@ function PermissionRequestCard({
response: AgentPermissionResponse; response: AgentPermissionResponse;
}) => { }) => {
if (!client) { if (!client) {
throw new Error("Daemon client unavailable"); throw new Error(t("common.errors.daemonClientUnavailable"));
} }
return client.respondToPermissionAndWait( return client.respondToPermissionAndWait(
input.agentId, input.agentId,
@@ -1042,7 +1055,7 @@ function PermissionRequestCard({
const footer = ( const footer = (
<> <>
<Text testID="permission-request-question" style={permissionStyles.question}> <Text testID="permission-request-question" style={permissionStyles.question}>
How would you like to proceed? {t("agentStream.permission.question")}
</Text> </Text>
<View style={optionsContainerStyle}> <View style={optionsContainerStyle}>
@@ -1094,7 +1107,7 @@ function PermissionRequestCard({
{planMarkdown ? ( {planMarkdown ? (
<PlanCard <PlanCard
title="Proposed plan" title={t("agentStream.permission.proposedPlan")}
text={planMarkdown} text={planMarkdown}
testID="permission-plan-card" testID="permission-plan-card"
disableOuterSpacing disableOuterSpacing
+10 -7
View File
@@ -69,6 +69,7 @@ import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-
import { useOpenProject } from "@/hooks/use-open-project"; import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { I18nProvider } from "@/i18n/provider";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { polyfillCrypto } from "@/polyfills/crypto"; import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client"; import { queryClient } from "@/query/query-client";
@@ -934,13 +935,15 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
function RootProviders({ children }: { children: ReactNode }) { function RootProviders({ children }: { children: ReactNode }) {
return ( return (
<QueryProvider> <QueryProvider>
<SafeAreaProvider> <I18nProvider>
<KeyboardProvider> <SafeAreaProvider>
<PortalProvider> <KeyboardProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider> <PortalProvider>
</PortalProvider> <BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</KeyboardProvider> </PortalProvider>
</SafeAreaProvider> </KeyboardProvider>
</SafeAreaProvider>
</I18nProvider>
</QueryProvider> </QueryProvider>
); );
} }
+14 -16
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native"; import { Alert, Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter, type Href } from "expo-router"; import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -120,6 +121,7 @@ function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
export default function PairScanScreen() { export default function PairScanScreen() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const params = useLocalSearchParams<{ const params = useLocalSearchParams<{
@@ -190,13 +192,13 @@ export default function PairScanScreen() {
navigateToPairedHost(profile.serverId); navigateToPairedHost(profile.serverId);
} catch (error) { } catch (error) {
lastScannedRef.current = null; lastScannedRef.current = null;
const message = error instanceof Error ? error.message : "Unable to pair host"; const message = error instanceof Error ? error.message : t("pairing.scan.unableToPair");
Alert.alert("Error", message); Alert.alert(t("pairing.scan.errorTitle"), message);
} finally { } finally {
setIsPairing(false); setIsPairing(false);
} }
}, },
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl], [isPairing, navigateToPairedHost, t, upsertDaemonFromOfferUrl],
); );
const handleRouterBack = useCallback(() => router.back(), [router]); const handleRouterBack = useCallback(() => router.back(), [router]);
@@ -216,15 +218,13 @@ export default function PairScanScreen() {
if (isWeb) { if (isWeb) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<BackHeader title="Scan QR" onBack={handleRouterBack} /> <BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
<View style={bodyStyle}> <View style={bodyStyle}>
<View style={styles.permissionCard}> <View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Not available on web</Text> <Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
<Text style={styles.permissionBody}> <Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
{`QR scanning isn't supported in the web build. Use "Paste link" instead.`}
</Text>
<Pressable style={styles.permissionButton} onPress={closeToSource}> <Pressable style={styles.permissionButton} onPress={closeToSource}>
<Text style={styles.permissionButtonText}>Back to Settings</Text> <Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
@@ -236,17 +236,15 @@ export default function PairScanScreen() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<BackHeader title="Scan QR" onBack={closeToSource} /> <BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
<View style={bodyStyle}> <View style={bodyStyle}>
{!granted ? ( {!granted ? (
<View style={styles.permissionCard}> <View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Camera permission</Text> <Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
<Text style={styles.permissionBody}> <Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</Text>
Allow camera access to scan the pairing QR code from your daemon.
</Text>
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}> <Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
<Text style={styles.permissionButtonText}>Grant permission</Text> <Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
</Pressable> </Pressable>
</View> </View>
) : ( ) : (
@@ -264,7 +262,7 @@ export default function PairScanScreen() {
<View style={CORNER_BL_STYLE} /> <View style={CORNER_BL_STYLE} />
<View style={CORNER_BR_STYLE} /> <View style={CORNER_BR_STYLE} />
</View> </View>
{isPairing ? <Text style={helperTextStyle}>Pairing…</Text> : null} {isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
</View> </View>
</View> </View>
)} )}
@@ -4,6 +4,7 @@ import {
type AssistantFileLinkClassification, type AssistantFileLinkClassification,
type InlinePathTarget, type InlinePathTarget,
} from "./parse"; } from "./parse";
import { i18n } from "@/i18n/i18next";
export interface AssistantFileLinkSource { export interface AssistantFileLinkSource {
href: string; href: string;
@@ -60,7 +61,7 @@ export interface FetchDaemonResolutionInput {
export class UnresolvedFileLinkError extends Error { export class UnresolvedFileLinkError extends Error {
constructor(readonly token: string) { constructor(readonly token: string) {
super(`No file found for ${token}`); super(i18n.t("common.errors.noFileFound", { token }));
this.name = "UnresolvedFileLinkError"; this.name = "UnresolvedFileLinkError";
} }
} }
@@ -1,5 +1,6 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import type { OpenFileDisposition } from "@/workspace/file-open"; import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
@@ -11,7 +12,6 @@ import {
import { import {
classifyForResolution, classifyForResolution,
fetchDaemonResolution, fetchDaemonResolution,
UnresolvedFileLinkError,
type AssistantFileLinkResolution, type AssistantFileLinkResolution,
type AssistantFileLinkSource, type AssistantFileLinkSource,
} from "./resolver"; } from "./resolver";
@@ -40,6 +40,7 @@ type AssistantFileLinkQueryKey = readonly [
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const; const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult { export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
const { t } = useTranslation();
const context = useAssistantFileLinkResolverContext(); const context = useAssistantFileLinkResolverContext();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const stableSource = useStableSource(source); const stableSource = useStableSource(source);
@@ -91,6 +92,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
disposition, disposition,
context, context,
queryClient, queryClient,
formatNoFileFoundMessage: (token) => t("common.errors.noFileFound", { token }),
}); });
}, },
); );
@@ -163,6 +165,7 @@ function openAssistantFileLink(input: {
disposition: OpenFileDisposition; disposition: OpenFileDisposition;
context: AssistantFileLinkResolverContextValue; context: AssistantFileLinkResolverContextValue;
queryClient: ReturnType<typeof useQueryClient>; queryClient: ReturnType<typeof useQueryClient>;
formatNoFileFoundMessage: (token: string) => string;
}): void { }): void {
const capturedConfig = input.context.configRef.current; const capturedConfig = input.context.configRef.current;
const capturedResolution = classifyForResolution(input.source, { const capturedResolution = classifyForResolution(input.source, {
@@ -211,7 +214,7 @@ function openAssistantFileLink(input: {
} catch (error) { } catch (error) {
await dispatchUnresolvedError({ await dispatchUnresolvedError({
error, error,
fallbackToken: capturedResolution.token, noFileFoundMessage: input.formatNoFileFoundMessage(capturedResolution.token),
capturedServerId: capturedConfig.serverId, capturedServerId: capturedConfig.serverId,
capturedWorkspaceRoot: capturedConfig.workspaceRoot, capturedWorkspaceRoot: capturedConfig.workspaceRoot,
context: input.context, context: input.context,
@@ -322,7 +325,7 @@ async function dispatchExternalUrl(input: {
async function dispatchUnresolvedError(input: { async function dispatchUnresolvedError(input: {
error: unknown; error: unknown;
fallbackToken: string; noFileFoundMessage: string;
capturedServerId?: string; capturedServerId?: string;
capturedWorkspaceRoot?: string; capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue; context: AssistantFileLinkResolverContextValue;
@@ -334,9 +337,7 @@ async function dispatchUnresolvedError(input: {
) { ) {
return; return;
} }
const token = current.toast?.show(input.noFileFoundMessage, {
input.error instanceof UnresolvedFileLinkError ? input.error.token : input.fallbackToken;
current.toast?.show(`No file found for ${token}`, {
variant: "error", variant: "error",
testID: "assistant-file-link-not-found-toast", testID: "assistant-file-link-not-found-toast",
}); });
@@ -8,6 +8,7 @@ export interface ClientSlashCommand {
name: string; name: string;
aliases: readonly string[]; aliases: readonly string[];
description: string; description: string;
descriptionKey: "composer.clientCommands.archiveAgent" | "composer.clientCommands.freshDraft";
argumentHint: string; argumentHint: string;
kind: ClientSlashCommandKind; kind: ClientSlashCommandKind;
execution: ClientSlashCommandExecution; execution: ClientSlashCommandExecution;
@@ -18,6 +19,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
name: "exit", name: "exit",
aliases: ["quit", "q"], aliases: ["quit", "q"],
description: "Archive the current agent", description: "Archive the current agent",
descriptionKey: "composer.clientCommands.archiveAgent",
argumentHint: "", argumentHint: "",
kind: "archive-agent", kind: "archive-agent",
execution: "immediate", execution: "immediate",
@@ -26,6 +28,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
name: "clear", name: "clear",
aliases: ["new"], aliases: ["new"],
description: "Archive this agent and start a fresh draft", description: "Archive this agent and start a fresh draft",
descriptionKey: "composer.clientCommands.freshDraft",
argumentHint: "", argumentHint: "",
kind: "replace-agent-with-draft", kind: "replace-agent-with-draft",
execution: "immediate", execution: "immediate",
@@ -1,6 +1,7 @@
import { forwardRef, useCallback, useEffect, useMemo } from "react"; import { forwardRef, useCallback, useEffect, useMemo } from "react";
import type { ReactNode, Ref } from "react"; import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native"; import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
@@ -304,6 +305,7 @@ export function SheetHeaderView({
testID?: string; testID?: string;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const titleStyle = useMemo( const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }], () => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground], [theme.colors.foreground],
@@ -327,7 +329,7 @@ export function SheetHeaderView({
hitSlop={8} hitSlop={8}
style={styles.headerBackButton} style={styles.headerBackButton}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"} accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")}
testID="sheet-header-back" testID="sheet-header-back"
> >
{({ pressed }) => ( {({ pressed }) => (
@@ -347,7 +349,11 @@ export function SheetHeaderView({
</View> </View>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null} {header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
{showCloseButton ? ( {showCloseButton ? (
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}> <Pressable
accessibilityLabel={t("common.actions.close")}
style={styles.closeButton}
onPress={onClose}
>
{({ pressed }) => ( {({ pressed }) => (
<X <X
size={16} size={16}
@@ -363,7 +369,7 @@ export function SheetHeaderView({
<AdaptiveTextInput <AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only // @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE} style={SEARCH_INPUT_STYLE}
placeholder={search.placeholder ?? "Search"} placeholder={search.placeholder ?? t("common.actions.search")}
resetKey={search.resetKey} resetKey={search.resetKey}
onChangeText={handleSearchChange} onChangeText={handleSearchChange}
autoCapitalize="none" autoCapitalize="none"
@@ -379,6 +385,7 @@ export function SheetHeaderView({
export function InlineHeaderView({ header }: { header: SheetHeader }) { export function InlineHeaderView({ header }: { header: SheetHeader }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const back = header.back; const back = header.back;
const handleBackPress = back?.onPress; const handleBackPress = back?.onPress;
const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions); const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions);
@@ -393,7 +400,9 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
hitSlop={8} hitSlop={8}
style={styles.headerBackButton} style={styles.headerBackButton}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"} accessibilityLabel={
back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")
}
testID="sheet-header-back" testID="sheet-header-back"
> >
{({ pressed }) => ( {({ pressed }) => (
@@ -417,7 +426,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
<AdaptiveTextInput <AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only // @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE} style={SEARCH_INPUT_STYLE}
placeholder={header.search.placeholder ?? "Search"} placeholder={header.search.placeholder ?? t("common.actions.search")}
resetKey={header.search.resetKey} resetKey={header.search.resetKey}
onChangeText={header.search.onChange} onChangeText={header.search.onChange}
autoCapitalize="none" autoCapitalize="none"
@@ -462,6 +471,7 @@ export function AdaptiveModalSheet({
presentation, presentation,
}: AdaptiveModalSheetProps) { }: AdaptiveModalSheetProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
@@ -585,7 +595,11 @@ export function AdaptiveModalSheet({
const desktopContent = ( const desktopContent = (
<View style={styles.desktopOverlay} testID={testID}> <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}> <View style={desktopCardStyle}>
{onFilesDropped ? ( {onFilesDropped ? (
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone> <FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
@@ -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 { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
const ADD_CONNECTION_HEADER: SheetHeader = { title: "Add connection" };
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
option: { option: {
flexDirection: "row", flexDirection: "row",
@@ -49,6 +48,8 @@ export function AddHostMethodModal({
onPasteLink, onPasteLink,
}: AddHostMethodModalProps) { }: AddHostMethodModalProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.connectionMethods.title") }), [t]);
const handleDirect = useCallback(() => { const handleDirect = useCallback(() => {
onDirectConnection(); onDirectConnection();
@@ -64,7 +65,7 @@ export function AddHostMethodModal({
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
header={ADD_CONNECTION_HEADER} header={header}
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
testID="add-host-method-modal" testID="add-host-method-modal"
@@ -73,13 +74,15 @@ export function AddHostMethodModal({
style={styles.option} style={styles.option}
onPress={handleDirect} onPress={handleDirect}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Direct connection" accessibilityLabel={t("pairing.connectionMethods.direct.title")}
testID="add-host-method-direct" testID="add-host-method-direct"
> >
<Link2 size={18} color={theme.colors.foreground} /> <Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}> <View style={styles.optionBody}>
<Text style={styles.optionText}>Direct connection</Text> <Text style={styles.optionText}>{t("pairing.connectionMethods.direct.title")}</Text>
<Text style={styles.optionSubtext}>Local network or VPN.</Text> <Text style={styles.optionSubtext}>
{t("pairing.connectionMethods.direct.description")}
</Text>
</View> </View>
</Pressable> </Pressable>
@@ -88,12 +91,14 @@ export function AddHostMethodModal({
style={styles.option} style={styles.option}
onPress={handleScan} onPress={handleScan}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Scan QR code" accessibilityLabel={t("pairing.connectionMethods.scanQr.title")}
> >
<QrCode size={18} color={theme.colors.foreground} /> <QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}> <View style={styles.optionBody}>
<Text style={styles.optionText}>Scan QR code</Text> <Text style={styles.optionText}>{t("pairing.connectionMethods.scanQr.title")}</Text>
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text> <Text style={styles.optionSubtext}>
{t("pairing.connectionMethods.scanQr.description")}
</Text>
</View> </View>
</Pressable> </Pressable>
) : null} ) : null}
@@ -102,13 +107,15 @@ export function AddHostMethodModal({
style={styles.option} style={styles.option}
onPress={handlePaste} onPress={handlePaste}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Paste pairing link" accessibilityLabel={t("pairing.connectionMethods.pasteLink.title")}
testID="add-host-method-pair-link" testID="add-host-method-pair-link"
> >
<ClipboardPaste size={18} color={theme.colors.foreground} /> <ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}> <View style={styles.optionBody}>
<Text style={styles.optionText}>Paste pairing link</Text> <Text style={styles.optionText}>{t("pairing.connectionMethods.pasteLink.title")}</Text>
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text> <Text style={styles.optionSubtext}>
{t("pairing.connectionMethods.pasteLink.description")}
</Text>
</View> </View>
</Pressable> </Pressable>
</AdaptiveModalSheet> </AdaptiveModalSheet>
+111 -45
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useReducer, useState } from "react"; import { useCallback, useMemo, useReducer, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native"; import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -15,7 +16,6 @@ import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adapt
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const; const FLEX_ONE_STYLE = { flex: 1 } as const;
const DIRECT_CONNECTION_HEADER: SheetHeader = { title: "Direct connection" };
interface DirectConnectionDraft { interface DirectConnectionDraft {
host: string; host: string;
@@ -31,6 +31,20 @@ interface PreparedDirectConnection {
password?: string; 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) => ({ const styles = StyleSheet.create((theme) => ({
field: { field: {
gap: theme.spacing[2], gap: theme.spacing[2],
@@ -128,14 +142,17 @@ function isIpv6Host(host: string): boolean {
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]"); 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 host = draft.host.trim();
const port = Number(draft.port.trim()); const port = Number(draft.port.trim());
if (!host) { if (!host) {
throw new Error("Host is required"); throw new Error(labels.hostRequired);
} }
if (!Number.isInteger(port) || port < 1 || port > 65535) { 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({ return serializeConnectionUriForStorage({
@@ -147,8 +164,11 @@ function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string {
}); });
} }
function prepareDirectConnection(draft: DirectConnectionDraft): PreparedDirectConnection { function prepareDirectConnection(
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft)); draft: DirectConnectionDraft,
labels: DirectConnectionLabels,
): PreparedDirectConnection {
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels));
const endpoint = parsed.isIpv6 const endpoint = parsed.isIpv6
? `[${parsed.host}]:${parsed.port}` ? `[${parsed.host}]:${parsed.port}`
: `${parsed.host}:${parsed.port}`; : `${parsed.host}:${parsed.port}`;
@@ -178,7 +198,10 @@ function normalizeTransportMessage(message: string | null | undefined): string |
return trimmed; return trimmed;
} }
function formatTechnicalTransportDetails(details: (string | null)[]): string | null { function formatTechnicalTransportDetails(
details: (string | null)[],
labels: DirectConnectionLabels,
): string | null {
const unique = Array.from( const unique = Array.from(
new Set( new Set(
details details
@@ -197,22 +220,24 @@ function formatTechnicalTransportDetails(details: (string | null)[]): string | n
}); });
if (allGeneric) { if (allGeneric) {
return `${unique[0]} (no additional details provided)`; return labels.noAdditionalDetails(unique[0] ?? "");
} }
return unique.join(" — "); return unique.join(" — ");
} }
function buildConnectionFailureCopy( function buildConnectionFailureCopy(input: {
endpoint: string, endpoint: string;
error: unknown, error: unknown;
): { title: string; detail: string | null; raw: string | null } { labels: DirectConnectionLabels;
const title = `We failed to connect to ${endpoint}.`; }): { title: string; detail: string | null; raw: string | null } {
const { endpoint, error, labels } = input;
const title = labels.failedToConnect(endpoint);
const raw = (() => { const raw = (() => {
if (error instanceof DaemonConnectionTestError) { if (error instanceof DaemonConnectionTestError) {
return ( return (
formatTechnicalTransportDetails([error.reason, error.lastError]) ?? formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
normalizeTransportMessage(error.message) normalizeTransportMessage(error.message)
); );
} }
@@ -228,26 +253,25 @@ function buildConnectionFailureCopy(
if (raw === "Incorrect password" || raw === "Password required") { if (raw === "Incorrect password" || raw === "Password required") {
detail = raw; detail = raw;
} else if (rawLower.includes("timed out")) { } else if (rawLower.includes("timed out")) {
detail = "Connection timed out. Check the host/port and your network."; detail = labels.timedOut;
} else if ( } else if (
rawLower.includes("econnrefused") || rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") || rawLower.includes("connection refused") ||
rawLower.includes("err_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")) { } 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")) { } else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = "Host is unreachable. Check your network and firewall."; detail = labels.hostUnreachable;
} else if ( } else if (
rawLower.includes("certificate") || rawLower.includes("certificate") ||
rawLower.includes("tls") || rawLower.includes("tls") ||
rawLower.includes("ssl") rawLower.includes("ssl")
) { ) {
detail = detail = labels.tlsError;
"TLS error. Direct connections use SSL only when a TLS terminator is in front of the daemon.";
} else { } else {
detail = "Unable to connect. Check the host/port and that the daemon is reachable."; detail = labels.unableToConnect;
} }
return { title, detail, raw }; return { title, detail, raw };
@@ -267,6 +291,7 @@ export interface AddHostModalProps {
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) { export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts(); const daemons = useHosts();
const { probeAndUpsertDirectConnection } = useHostMutations(); const { probeAndUpsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
@@ -308,6 +333,23 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
() => ({ checked: useTls, disabled: isSaving }), () => ({ checked: useTls, disabled: isSaving }),
[isSaving, useTls], [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(() => { const handleClose = useCallback(() => {
if (isSaving) return; if (isSaving) return;
@@ -328,9 +370,13 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
let connection: PreparedDirectConnection; let connection: PreparedDirectConnection;
try { try {
connection = prepareDirectConnection({ host, port, useTls, password }); connection = prepareDirectConnection(
{ host, port, useTls, password },
directConnectionLabels,
);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Invalid connection"; const message =
error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
setErrorMessage(message); setErrorMessage(message);
return; return;
} }
@@ -349,10 +395,20 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onSaved?.({ profile, serverId, hostname, isNewHost }); onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose(); handleClose();
} catch (error) { } 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; let combined: string;
if (rawDetail && detail && rawDetail !== detail) { 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) { } else if (detail) {
combined = `${title}\n${detail}`; combined = `${title}\n${detail}`;
} else { } else {
@@ -360,13 +416,14 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
} }
setErrorMessage(combined); setErrorMessage(combined);
if (!isMobile) { if (!isMobile) {
Alert.alert("Connection failed", combined); Alert.alert(t("pairing.direct.errors.failedTitle"), combined);
} }
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [ }, [
daemons, daemons,
directConnectionLabels,
handleClose, handleClose,
host, host,
isMobile, isMobile,
@@ -375,6 +432,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
password, password,
port, port,
probeAndUpsertDirectConnection, probeAndUpsertDirectConnection,
t,
useTls, useTls,
]); ]);
@@ -398,7 +456,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const handleToggleAdvanced = useCallback(() => { const handleToggleAdvanced = useCallback(() => {
if (!isAdvancedOpen) { if (!isAdvancedOpen) {
try { try {
setAdvancedUri(buildConnectionUriFromDraft({ host, port, useTls, password })); setAdvancedUri(
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
);
} catch { } catch {
setAdvancedUri(""); setAdvancedUri("");
} }
@@ -419,27 +479,27 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setErrorMessage(""); setErrorMessage("");
} }
setIsAdvancedOpen(false); setIsAdvancedOpen(false);
}, [advancedUri, host, isAdvancedOpen, password, port, useTls]); }, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight; const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
const PasswordIcon = isPasswordVisible ? EyeOff : Eye; const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
header={DIRECT_CONNECTION_HEADER} header={header}
visible={visible} visible={visible}
onClose={handleClose} onClose={handleClose}
testID="add-host-modal" 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={styles.portRow}>
<View style={hostFieldStyle}> <View style={hostFieldStyle}>
<Text style={styles.label}>Host</Text> <Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
<AdaptiveTextInput <AdaptiveTextInput
testID="direct-host-input" testID="direct-host-input"
nativeID="direct-host-input" nativeID="direct-host-input"
accessibilityLabel="Host" accessibilityLabel={t("pairing.direct.fields.host")}
initialValue={host} initialValue={host}
resetKey={`direct-host-${inputResetKey}`} resetKey={`direct-host-${inputResetKey}`}
value={host} value={host}
@@ -455,11 +515,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
/> />
</View> </View>
<View style={portFieldStyle}> <View style={portFieldStyle}>
<Text style={styles.label}>Port</Text> <Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
<AdaptiveTextInput <AdaptiveTextInput
testID="direct-port-input" testID="direct-port-input"
nativeID="direct-port-input" nativeID="direct-port-input"
accessibilityLabel="Port" accessibilityLabel={t("pairing.direct.fields.port")}
initialValue={port} initialValue={port}
resetKey={`direct-port-${inputResetKey}`} resetKey={`direct-port-${inputResetKey}`}
value={port} value={port}
@@ -482,7 +542,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onPress={handleToggleUseTls} onPress={handleToggleUseTls}
disabled={isSaving} disabled={isSaving}
accessibilityRole="checkbox" accessibilityRole="checkbox"
accessibilityLabel="Use SSL" accessibilityLabel={t("pairing.direct.fields.useSsl")}
accessibilityState={useTlsAccessibilityState} accessibilityState={useTlsAccessibilityState}
testID="direct-ssl-toggle" testID="direct-ssl-toggle"
> >
@@ -493,21 +553,21 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
</View> </View>
) : null} ) : null}
</View> </View>
<Text style={styles.label}>Use SSL</Text> <Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
</Pressable> </Pressable>
<View style={styles.field}> <View style={styles.field}>
<Text style={styles.label}>Password</Text> <Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
<View style={styles.passwordRow}> <View style={styles.passwordRow}>
<AdaptiveTextInput <AdaptiveTextInput
testID="direct-password-input" testID="direct-password-input"
nativeID="direct-password-input" nativeID="direct-password-input"
accessibilityLabel="Password" accessibilityLabel={t("pairing.direct.fields.password")}
initialValue={password} initialValue={password}
resetKey={`direct-password-${inputResetKey}`} resetKey={`direct-password-${inputResetKey}`}
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
placeholder="Optional" placeholder={t("pairing.direct.fields.optional")}
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
style={passwordInputStyle} style={passwordInputStyle}
autoCapitalize="none" autoCapitalize="none"
@@ -522,7 +582,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onPress={handleTogglePasswordVisibility} onPress={handleTogglePasswordVisibility}
disabled={isSaving} disabled={isSaving}
accessibilityRole="button" 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" testID="direct-password-visibility-toggle"
> >
<PasswordIcon size={18} color={theme.colors.foregroundMuted} /> <PasswordIcon size={18} color={theme.colors.foregroundMuted} />
@@ -536,17 +600,19 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onPress={handleToggleAdvanced} onPress={handleToggleAdvanced}
disabled={isSaving} disabled={isSaving}
accessibilityRole="button" 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" testID="direct-host-advanced-toggle"
> >
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} /> <AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.advancedText}>Advanced</Text> <Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
</Pressable> </Pressable>
{isAdvancedOpen ? ( {isAdvancedOpen ? (
<AdaptiveTextInput <AdaptiveTextInput
testID="direct-host-uri-input" testID="direct-host-uri-input"
nativeID="direct-host-uri-input" nativeID="direct-host-uri-input"
accessibilityLabel="Connection URI" accessibilityLabel={t("pairing.direct.fields.connectionUri")}
initialValue={advancedUri} initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`} resetKey={`direct-host-uri-${inputResetKey}`}
value={advancedUri} value={advancedUri}
@@ -572,7 +638,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onPress={handleCancel} onPress={handleCancel}
disabled={isSaving} disabled={isSaving}
> >
Cancel {t("pairing.direct.actions.cancel")}
</Button> </Button>
<Button <Button
style={FLEX_ONE_STYLE} style={FLEX_ONE_STYLE}
@@ -582,7 +648,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
leftIcon={connectIcon} leftIcon={connectIcon}
testID="direct-host-submit" testID="direct-host-submit"
> >
{isSaving ? "Connecting..." : "Connect"} {isSaving ? t("pairing.direct.actions.connecting") : t("pairing.direct.actions.connect")}
</Button> </Button>
</View> </View>
</AdaptiveModalSheet> </AdaptiveModalSheet>
+68 -34
View File
@@ -11,6 +11,8 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react"; import { useCallback, useMemo, useState, type ReactElement } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
@@ -33,8 +35,18 @@ interface AgentListProps {
showAttentionIndicator?: boolean; 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 FlatListItem =
| { type: "header"; key: string; title: string } | { type: "header"; key: string; section: DateSectionKey }
| { type: "agent"; key: string; agent: AggregatedAgent }; | { type: "agent"; key: string; agent: AggregatedAgent };
function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent { 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 now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000); const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
@@ -105,35 +117,50 @@ function deriveDateSectionLabel(lastActivityAt: Date): string {
); );
if (activityStart.getTime() >= todayStart.getTime()) { if (activityStart.getTime() >= todayStart.getTime()) {
return "Today"; return "today";
} }
if (activityStart.getTime() >= yesterdayStart.getTime()) { if (activityStart.getTime() >= yesterdayStart.getTime()) {
return "Yesterday"; return "yesterday";
} }
const diffTime = todayStart.getTime() - activityStart.getTime(); const diffTime = todayStart.getTime() - activityStart.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7) { if (diffDays <= 7) {
return "This week"; return "thisWeek";
} }
if (diffDays <= 30) { 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) { switch (status) {
case "initializing": case "initializing":
return "Starting"; return t("agentList.status.initializing");
case "idle": case "idle":
return "Idle"; return t("agentList.status.idle");
case "running": case "running":
return "Running"; return t("agentList.status.running");
case "error": case "error":
return "Error"; return t("agentList.status.error");
case "closed": case "closed":
return "Closed"; return t("agentList.status.closed");
default: default:
return status; return status;
} }
@@ -188,12 +215,14 @@ function SessionRow({
onLongPress: (agent: AggregatedAgent) => void; onLongPress: (agent: AggregatedAgent) => void;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const timeAgo = formatTimeAgo(agent.lastActivityAt); const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`; const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey; const isSelected = selectedAgentId === agentKey;
const statusLabel = formatStatusLabel(agent.status); const statusLabel = formatStatusLabel(t, agent.status);
const projectPath = shortenPath(agent.cwd); const projectPath = shortenPath(agent.cwd);
const ProviderIcon = getProviderIcon(agent.provider); const ProviderIcon = getProviderIcon(agent.provider);
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
const pressableStyle = useCallback( const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
@@ -231,14 +260,19 @@ function SessionRow({
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View> </View>
<Text style={sessionTitleStyle} numberOfLines={1}> <Text style={sessionTitleStyle} numberOfLines={1}>
{agent.title || "New session"} {agent.title || t("agentList.fallbackTitle")}
</Text> </Text>
{agent.archivedAt ? <SessionBadge label="Archived" icon={archivedIcon} /> : null} {agent.archivedAt ? (
{(agent.pendingPermissionCount ?? 0) > 0 ? ( <SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" /> ) : null}
{pendingPermissionCount > 0 ? (
<SessionBadge
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
tone="warning"
/>
) : null} ) : null}
{!isMobile && showAttentionIndicator && agent.requiresAttention ? ( {!isMobile && showAttentionIndicator && agent.requiresAttention ? (
<SessionBadge label="Attention" tone="danger" /> <SessionBadge label={t("agentList.badges.attention")} tone="danger" />
) : null} ) : null}
</View> </View>
{isMobile && ( {isMobile && (
@@ -272,7 +306,7 @@ function SessionRow({
)} )}
{isMobile && showAttentionIndicator && agent.requiresAttention ? ( {isMobile && showAttentionIndicator && agent.requiresAttention ? (
<View style={styles.rowTrailing}> <View style={styles.rowTrailing}>
<SessionBadge label="Attention" tone="danger" /> <SessionBadge label={t("agentList.badges.attention")} tone="danger" />
</View> </View>
) : null} ) : null}
</Pressable> </Pressable>
@@ -289,6 +323,7 @@ export function AgentList({
showAttentionIndicator = true, showAttentionIndicator = true,
}: AgentListProps) { }: AgentListProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null); const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
@@ -354,22 +389,21 @@ export function AgentList({
}, [actionAgent, actionClient, archiveAgent]); }, [actionAgent, actionClient, archiveAgent]);
const flatItems = useMemo((): FlatListItem[] => { const flatItems = useMemo((): FlatListItem[] => {
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const; const buckets = new Map<DateSectionKey, AggregatedAgent[]>();
const buckets = new Map<string, AggregatedAgent[]>();
for (const agent of agents) { for (const agent of agents) {
const label = deriveDateSectionLabel(agent.lastActivityAt); const section = deriveDateSectionKey(agent.lastActivityAt);
const existing = buckets.get(label) ?? []; const existing = buckets.get(section) ?? [];
existing.push(agent); existing.push(agent);
buckets.set(label, existing); buckets.set(section, existing);
} }
const result: FlatListItem[] = []; const result: FlatListItem[] = [];
for (const label of order) { for (const section of DATE_SECTION_ORDER) {
const data = buckets.get(label); const data = buckets.get(section);
if (!data || data.length === 0) { if (!data || data.length === 0) {
continue; continue;
} }
result.push({ type: "header", key: `header:${label}`, title: label }); result.push({ type: "header", key: `header:${section}`, section });
for (const agent of data) { for (const agent of data) {
result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent }); result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent });
} }
@@ -382,7 +416,7 @@ export function AgentList({
if (item.type === "header") { if (item.type === "header") {
return ( return (
<View style={styles.sectionHeading}> <View style={styles.sectionHeading}>
<Text style={styles.sectionTitle}>{item.title}</Text> <Text style={styles.sectionTitle}>{formatDateSectionLabel(t, item.section)}</Text>
</View> </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, []); const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
@@ -454,8 +488,8 @@ export function AgentList({
<View style={styles.sheetHandle} /> <View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}> <Text style={styles.sheetTitle}>
{isActionDaemonUnavailable {isActionDaemonUnavailable
? "Host offline" ? t("agentList.archiveSheet.hostOffline")
: "This agent is still running. Archiving it will stop the agent."} : t("agentList.archiveSheet.runningAgent")}
</Text> </Text>
<View style={styles.sheetButtonRow}> <View style={styles.sheetButtonRow}>
<Pressable <Pressable
@@ -463,7 +497,7 @@ export function AgentList({
onPress={handleCloseActionSheet} onPress={handleCloseActionSheet}
testID="agent-action-cancel" testID="agent-action-cancel"
> >
<Text style={styles.sheetCancelText}>Cancel</Text> <Text style={styles.sheetCancelText}>{t("common.actions.cancel")}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
disabled={isActionDaemonUnavailable} disabled={isActionDaemonUnavailable}
@@ -471,7 +505,7 @@ export function AgentList({
onPress={handleArchiveAgent} onPress={handleArchiveAgent}
testID="agent-action-archive" testID="agent-action-archive"
> >
<Text style={sheetArchiveTextStyle}>Archive</Text> <Text style={sheetArchiveTextStyle}>{t("agentList.archiveSheet.archive")}</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import Animated from "react-native-reanimated"; import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -15,6 +16,7 @@ interface ArchivedAgentCalloutProps {
} }
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) { export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
@@ -43,14 +45,14 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
<View style={styles.inputAreaContainer}> <View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}> <View style={styles.inputAreaContent}>
<View style={styles.callout}> <View style={styles.callout}>
<Text style={styles.calloutText}>This agent is archived</Text> <Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
onPress={handleUnarchive} onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving} disabled={!isConnected || isUnarchiving}
> >
Unarchive {t("agentPanel.archived.unarchive")}
</Button> </Button>
</View> </View>
</View> </View>
@@ -55,6 +55,17 @@ vi.mock("@/constants/platform", () => ({
isNative: false, 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", () => ({ vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
})); }));
@@ -4,6 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Image as ExpoImage } from "expo-image"; import { Image as ExpoImage } from "expo-image";
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { AttachmentMetadata } from "@/attachments/types"; import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
@@ -15,6 +16,7 @@ interface AttachmentLightboxProps {
export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) { export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const url = useAttachmentPreviewUrl(metadata); const url = useAttachmentPreviewUrl(metadata);
const [errored, setErrored] = useState(false); const [errored, setErrored] = useState(false);
@@ -63,14 +65,14 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
<Pressable <Pressable
testID="attachment-lightbox-backdrop" testID="attachment-lightbox-backdrop"
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Dismiss image" accessibilityLabel={t("message.attachments.dismissImage")}
onPress={onClose} onPress={onClose}
style={styles.backdrop} style={styles.backdrop}
/> />
<View style={styles.contentLayer}> <View style={styles.contentLayer}>
<View style={styles.imageArea}> <View style={styles.imageArea}>
{hasError ? ( {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}> <Pressable onPress={noopPress} style={styles.imagePressable}>
<ExpoImage <ExpoImage
@@ -86,7 +88,7 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
<Pressable <Pressable
testID="attachment-lightbox-close" testID="attachment-lightbox-close"
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Close image" accessibilityLabel={t("message.attachments.closeImage")}
hitSlop={8} hitSlop={8}
onPress={onClose} onPress={onClose}
style={closeButtonStyle} style={closeButtonStyle}
@@ -3,6 +3,7 @@ import { Pressable, View, type PressableStateCallbackType } from "react-native";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native"; import { ChevronDown, GitBranch } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import type { ComboboxProps } from "@/components/ui/combobox"; import type { ComboboxProps } from "@/components/ui/combobox";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -27,6 +28,7 @@ export function BranchSwitcher({
isGitCheckout, isGitCheckout,
}: BranchSwitcherProps) { }: BranchSwitcherProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const anchorRef = useRef<View>(null); const anchorRef = useRef<View>(null);
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
@@ -91,7 +93,7 @@ export function BranchSwitcher({
onPress={handleOpen} onPress={handleOpen}
style={triggerStyle} style={triggerStyle}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`} accessibilityLabel={t("branchSwitcher.currentBranch", { branchName: currentBranchName })}
> >
{titleContent} {titleContent}
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null} {!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
@@ -101,10 +103,10 @@ export function BranchSwitcher({
value={currentBranchName} value={currentBranchName}
onSelect={handleBranchSelect} onSelect={handleBranchSelect}
searchable searchable
placeholder="Switch branch..." placeholder={t("branchSwitcher.placeholder")}
searchPlaceholder="Filter branches..." searchPlaceholder={t("branchSwitcher.searchPlaceholder")}
emptyText="No branches found." emptyText={t("branchSwitcher.empty")}
title="Switch branch" title={t("branchSwitcher.title")}
open={isOpen} open={isOpen}
onOpenChange={setIsOpen} onOpenChange={setIsOpen}
anchorRef={anchorRef} anchorRef={anchorRef}
@@ -10,6 +10,7 @@ import {
import { Pressable, Text, TextInput, View } from "react-native"; import { Pressable, Text, TextInput, View } from "react-native";
import { ArrowLeft, ArrowRight, MousePointer2, PencilRuler, RotateCw } from "lucide-react-native"; import { ArrowLeft, ArrowRight, MousePointer2, PencilRuler, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { import {
buildWorkspaceAttachmentScopeKey, buildWorkspaceAttachmentScopeKey,
useWorkspaceAttachments, useWorkspaceAttachments,
@@ -55,7 +56,7 @@ function truncateText(value: string, maxLength: number): string {
return value.length > maxLength ? `${value.slice(0, maxLength).trim()}...` : value; 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 & { const details = event as Event & {
errorCode?: unknown; errorCode?: unknown;
errorDescription?: unknown; errorDescription?: unknown;
@@ -69,7 +70,7 @@ function getWebviewLoadErrorMessage(event: Event): string | null {
const description = const description =
typeof details.errorDescription === "string" && details.errorDescription.trim() typeof details.errorDescription === "string" && details.errorDescription.trim()
? details.errorDescription.trim() ? details.errorDescription.trim()
: "Failed to load page"; : failedToLoadLabel;
const url = const url =
typeof details.validatedURL === "string" && details.validatedURL.trim() typeof details.validatedURL === "string" && details.validatedURL.trim()
? details.validatedURL.trim() ? details.validatedURL.trim()
@@ -78,7 +79,7 @@ function getWebviewLoadErrorMessage(event: Event): string | null {
return url ? `${description}: ${url}` : description; 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 instanceof Error && error.message.trim()) {
if (error.message.includes("ERR_ABORTED") || error.message.includes("ERR_BLOCKED_BY_CLIENT")) { if (error.message.includes("ERR_ABORTED") || error.message.includes("ERR_BLOCKED_BY_CLIENT")) {
return null; return null;
@@ -91,18 +92,21 @@ function getLoadUrlRejectionMessage(error: unknown): string | null {
} }
return error.trim(); 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 { try {
const parsed = new URL(url); const parsed = new URL(url);
if (ALLOWED_BROWSER_PROTOCOLS.has(parsed.protocol) || parsed.href === "about:blank") { if (ALLOWED_BROWSER_PROTOCOLS.has(parsed.protocol) || parsed.href === "about:blank") {
return null; return null;
} }
return `Blocked unsupported browser URL: ${parsed.protocol}`; return labels.unsupportedProtocol(parsed.protocol);
} catch { } catch {
return "Invalid browser URL"; return labels.invalidUrl;
} }
} }
@@ -283,6 +287,7 @@ export function BrowserPane({
onFocusPane?: () => void; onFocusPane?: () => void;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const browser = useBrowserStore((state) => state.browsersById[browserId] ?? null); const browser = useBrowserStore((state) => state.browsersById[browserId] ?? null);
const updateBrowser = useBrowserStore((state) => state.updateBrowser); const updateBrowser = useBrowserStore((state) => state.updateBrowser);
const webviewRef = useRef<ElectronWebview | null>(null); const webviewRef = useRef<ElectronWebview | null>(null);
@@ -327,6 +332,17 @@ export function BrowserPane({
() => [styles.metaError, { color: theme.colors.palette.red[500] }], () => [styles.metaError, { color: theme.colors.palette.red[500] }],
[theme.colors.palette.red], [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(() => { useEffect(() => {
const nextUrl = browser?.url ?? "https://example.com"; const nextUrl = browser?.url ?? "https://example.com";
@@ -384,7 +400,10 @@ export function BrowserPane({
host.replaceChildren(); host.replaceChildren();
const initialUnsafeNavigationMessage = getUnsafeNavigationMessage(initialUrlRef.current); const initialUnsafeNavigationMessage = getUnsafeNavigationMessage(
initialUrlRef.current,
browserErrorLabelsRef.current,
);
const webview = document.createElement("webview") as ElectronWebview; const webview = document.createElement("webview") as ElectronWebview;
webviewRef.current = webview; webviewRef.current = webview;
webview.setAttribute("partition", `persist:paseo-browser-${browserId}`); webview.setAttribute("partition", `persist:paseo-browser-${browserId}`);
@@ -459,7 +478,7 @@ export function BrowserPane({
updateBrowserRef.current(browserIdRef.current, { faviconUrl: favicons[0] ?? null }); updateBrowserRef.current(browserIdRef.current, { faviconUrl: favicons[0] ?? null });
}; };
const handleLoadFailed = (event: Event) => { const handleLoadFailed = (event: Event) => {
const message = getWebviewLoadErrorMessage(event); const message = getWebviewLoadErrorMessage(event, browserErrorLabelsRef.current.failedToLoad);
if (!message) { if (!message) {
return; return;
} }
@@ -519,43 +538,46 @@ export function BrowserPane({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [browserId, onFocusPane]); }, [browserId, onFocusPane]);
const navigate = useCallback((nextUrl: string) => { const navigate = useCallback(
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl); (nextUrl: string) => {
const webview = webviewRef.current; const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
const unsafeNavigationMessage = getUnsafeNavigationMessage(normalizedUrl); const webview = webviewRef.current;
const previousUrl = browserRef.current?.url ?? initialUrlRef.current; const unsafeNavigationMessage = getUnsafeNavigationMessage(normalizedUrl, browserErrorLabels);
pendingNavigationUrlRef.current = unsafeNavigationMessage ? null : normalizedUrl; const previousUrl = browserRef.current?.url ?? initialUrlRef.current;
updateBrowserRef.current(browserIdRef.current, { pendingNavigationUrlRef.current = unsafeNavigationMessage ? null : normalizedUrl;
url: normalizedUrl,
isLoading: unsafeNavigationMessage === null,
...(normalizedUrl !== previousUrl ? { faviconUrl: null } : {}),
lastError: null,
});
setDraftUrl((current) => (current === normalizedUrl ? current : normalizedUrl));
if (unsafeNavigationMessage) {
updateBrowserRef.current(browserIdRef.current, { updateBrowserRef.current(browserIdRef.current, {
isLoading: false, url: normalizedUrl,
lastError: unsafeNavigationMessage, isLoading: unsafeNavigationMessage === null,
...(normalizedUrl !== previousUrl ? { faviconUrl: null } : {}),
lastError: null,
}); });
return; setDraftUrl((current) => (current === normalizedUrl ? current : normalizedUrl));
} if (unsafeNavigationMessage) {
if (webview?.loadURL) {
void webview.loadURL(normalizedUrl).catch((error: unknown) => {
const message = getLoadUrlRejectionMessage(error);
if (!message) {
return;
}
updateBrowserRef.current(browserIdRef.current, { updateBrowserRef.current(browserIdRef.current, {
isLoading: false, isLoading: false,
lastError: message, lastError: unsafeNavigationMessage,
}); });
}); return;
return; }
} if (webview?.loadURL) {
if (webview) { void webview.loadURL(normalizedUrl).catch((error: unknown) => {
webview.setAttribute("src", normalizedUrl); 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(() => { const handleBack = useCallback(() => {
webviewRef.current?.goBack?.(); webviewRef.current?.goBack?.();
@@ -929,10 +951,8 @@ export function BrowserPane({
if (!isElectronRuntime()) { if (!isElectronRuntime()) {
return ( return (
<View style={styles.unavailableState}> <View style={styles.unavailableState}>
<Text style={titleStyle}>Browser is desktop-only</Text> <Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
<Text style={subtitleStyle}> <Text style={subtitleStyle}>{t("workspace.browser.unavailable.subtitle")}</Text>
Open this workspace in Electron to use the built-in browser.
</Text>
</View> </View>
); );
} }
@@ -943,7 +963,7 @@ export function BrowserPane({
<View style={styles.chromeLeft}> <View style={styles.chromeLeft}>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Back" accessibilityLabel={t("workspace.browser.controls.back")}
disabled={!browser?.canGoBack} disabled={!browser?.canGoBack}
onPress={handleBack} onPress={handleBack}
style={backIconButtonStyle} style={backIconButtonStyle}
@@ -952,7 +972,7 @@ export function BrowserPane({
</Pressable> </Pressable>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Forward" accessibilityLabel={t("workspace.browser.controls.forward")}
disabled={!browser?.canGoForward} disabled={!browser?.canGoForward}
onPress={handleForward} onPress={handleForward}
style={forwardIconButtonStyle} style={forwardIconButtonStyle}
@@ -961,7 +981,11 @@ export function BrowserPane({
</Pressable> </Pressable>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={browser?.isLoading ? "Stop loading" : "Refresh"} accessibilityLabel={
browser?.isLoading
? t("workspace.browser.controls.stopLoading")
: t("workspace.browser.controls.refresh")
}
onPress={handleRefresh} onPress={handleRefresh}
style={baseIconButtonStyle} style={baseIconButtonStyle}
> >
@@ -970,13 +994,13 @@ export function BrowserPane({
</View> </View>
<View style={styles.urlBarWrap}> <View style={styles.urlBarWrap}>
<TextInput <TextInput
accessibilityLabel="Browser URL" accessibilityLabel={t("workspace.browser.controls.browserUrl")}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
onChangeText={setDraftUrl} onChangeText={setDraftUrl}
onFocus={handleUrlBarFocus} onFocus={handleUrlBarFocus}
onSubmitEditing={handleNavigateDraftUrl} onSubmitEditing={handleNavigateDraftUrl}
placeholder="Enter URL" placeholder={t("workspace.browser.controls.enterUrl")}
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
ref={urlInputRef} ref={urlInputRef}
style={urlInputStyle} style={urlInputStyle}
@@ -988,7 +1012,7 @@ export function BrowserPane({
<> <>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Open browser dev tools" accessibilityLabel={t("workspace.browser.controls.openDevTools")}
onPress={handleOpenDevTools} onPress={handleOpenDevTools}
style={baseIconButtonStyle} style={baseIconButtonStyle}
> >
@@ -996,7 +1020,11 @@ export function BrowserPane({
</Pressable> </Pressable>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={selectorActive ? "Cancel element selector" : "Select element"} accessibilityLabel={
selectorActive
? t("workspace.browser.controls.cancelSelector")
: t("workspace.browser.controls.selectElement")
}
onPress={handleToggleElementSelector} onPress={handleToggleElementSelector}
style={selectorIconButtonStyle} style={selectorIconButtonStyle}
> >
+4 -2
View File
@@ -1,6 +1,7 @@
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
interface BrowserPaneProps { interface BrowserPaneProps {
browserId: string; browserId: string;
@@ -13,6 +14,7 @@ interface BrowserPaneProps {
export function BrowserPane({ browserId }: BrowserPaneProps) { export function BrowserPane({ browserId }: BrowserPaneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const titleStyle = useMemo( const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }], () => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground], [theme.colors.foreground],
@@ -24,8 +26,8 @@ export function BrowserPane({ browserId }: BrowserPaneProps) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={titleStyle}>Browser is desktop-only</Text> <Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
<Text style={subtitleStyle}>Browser session {browserId}</Text> <Text style={subtitleStyle}>{t("workspace.browser.session", { browserId })}</Text>
</View> </View>
); );
} }
@@ -1,5 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
interface BrowserPaneProps { interface BrowserPaneProps {
@@ -12,6 +13,7 @@ interface BrowserPaneProps {
} }
export function BrowserPane({ browserId }: BrowserPaneProps) { export function BrowserPane({ browserId }: BrowserPaneProps) {
const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const titleStyle = useMemo( const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }], () => [styles.title, { color: theme.colors.foreground }],
@@ -24,11 +26,9 @@ export function BrowserPane({ browserId }: BrowserPaneProps) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={titleStyle}>Browser is desktop-only</Text> <Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
<Text style={subtitleStyle}> <Text style={subtitleStyle}>{t("workspace.browser.unavailable.subtitle")}</Text>
Open this workspace in Electron to use the built-in browser. <Text style={subtitleStyle}>{t("workspace.browser.session", { browserId })}</Text>
</Text>
<Text style={subtitleStyle}>Browser session {browserId}</Text>
</View> </View>
); );
} }
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
View, View,
Text, Text,
@@ -144,6 +145,7 @@ function ModelRow({
onToggleFavorite?: (provider: string, modelId: string) => void; onToggleFavorite?: (provider: string, modelId: string) => void;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const ProviderIcon = getProviderIcon(row.provider); const ProviderIcon = getProviderIcon(row.provider);
const handleToggleFavorite = useCallback( const handleToggleFavorite = useCallback(
@@ -166,7 +168,9 @@ function ModelRow({
hitSlop={8} hitSlop={8}
style={favoriteButtonStyle} style={favoriteButtonStyle}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"} accessibilityLabel={
isFavorite ? t("modelSelector.unfavoriteModel") : t("modelSelector.favoriteModel")
}
testID={`favorite-model-${row.provider}-${row.modelId}`} testID={`favorite-model-${row.provider}-${row.modelId}`}
> >
{({ hovered }) => { {({ hovered }) => {
@@ -193,6 +197,7 @@ function ModelRow({
theme.colors.palette.amber, theme.colors.palette.amber,
theme.colors.foregroundMuted, theme.colors.foregroundMuted,
theme.colors.border, theme.colors.border,
t,
], ],
); );
@@ -256,6 +261,7 @@ function FavoritesSection({
onSelect: (provider: string, modelId: string) => void; onSelect: (provider: string, modelId: string) => void;
onToggleFavorite?: (provider: string, modelId: string) => void; onToggleFavorite?: (provider: string, modelId: string) => void;
}) { }) {
const { t } = useTranslation();
if (favoriteRows.length === 0) { if (favoriteRows.length === 0) {
return null; return null;
} }
@@ -263,7 +269,7 @@ function FavoritesSection({
return ( return (
<View style={styles.favoritesContainer}> <View style={styles.favoritesContainer}>
<View style={styles.sectionHeading}> <View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>Favorites</Text> <Text style={styles.sectionHeadingText}>{t("modelSelector.favorites")}</Text>
</View> </View>
{favoriteRows.map((row) => ( {favoriteRows.map((row) => (
<SelectableModelRow <SelectableModelRow
@@ -295,6 +301,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) { function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const ProvIcon = getProviderIcon(provider.id); const ProvIcon = getProviderIcon(provider.id);
const selection = provider.modelSelection; const selection = provider.modelSelection;
@@ -306,7 +313,11 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
if (selection.kind === "models") { if (selection.kind === "models") {
const count = selection.rows.length; const count = selection.rows.length;
stateNode = ( 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") { } else if (selection.kind === "loading") {
stateNode = ( stateNode = (
@@ -316,14 +327,14 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
color={theme.colors.foregroundMuted} color={theme.colors.foregroundMuted}
style={styles.rowSpinner} style={styles.rowSpinner}
/> />
<Text style={styles.drillDownCount}>Loading</Text> <Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
</View> </View>
); );
} else { } else {
stateNode = ( stateNode = (
<View style={styles.rowStateInline}> <View style={styles.rowStateInline}>
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownCount}>Error</Text> <Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
</View> </View>
); );
} }
@@ -431,6 +442,7 @@ function ProviderErrorEmptyState({
isRetryingProvider: boolean; isRetryingProvider: boolean;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const handleRetry = useCallback(() => { const handleRetry = useCallback(() => {
onRetryProvider?.(providerId); onRetryProvider?.(providerId);
}, [onRetryProvider, providerId]); }, [onRetryProvider, providerId]);
@@ -440,7 +452,7 @@ function ProviderErrorEmptyState({
<Text style={styles.emptyStateText}>{message}</Text> <Text style={styles.emptyStateText}>{message}</Text>
{onRetryProvider ? ( {onRetryProvider ? (
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}> <Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
{isRetryingProvider ? "Retrying…" : "Retry"} {isRetryingProvider ? t("modelSelector.retrying") : t("modelSelector.retry")}
</Button> </Button>
) : null} ) : null}
</View> </View>
@@ -461,6 +473,7 @@ function SelectorContent({
isRetryingProvider, isRetryingProvider,
}: SelectorContentProps) { }: SelectorContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
const selectedViewProvider = useMemo( const selectedViewProvider = useMemo(
() => () =>
@@ -484,7 +497,7 @@ function SelectorContent({
const emptyState = ( const emptyState = (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <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> </View>
); );
@@ -501,7 +514,7 @@ function SelectorContent({
color={theme.colors.foregroundMuted} color={theme.colors.foregroundMuted}
style={styles.rowSpinner} style={styles.rowSpinner}
/> />
<Text style={styles.emptyStateText}>Loading</Text> <Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
</View> </View>
); );
} }
@@ -569,6 +582,7 @@ export function CombinedModelSelector({
serverId = null, serverId = null,
}: CombinedModelSelectorProps) { }: CombinedModelSelectorProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const anchorRef = useRef<View>(null); const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isContentReady, setIsContentReady] = useState(platformIsWeb); const [isContentReady, setIsContentReady] = useState(platformIsWeb);
@@ -653,12 +667,15 @@ export function CombinedModelSelector({
}, [providers, view]); }, [providers, view]);
const triggerLabel = useMemo(() => { const triggerLabel = useMemo(() => {
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") { if (
selectedModelLabel === t("modelSelector.loading") ||
selectedModelLabel === t("modelSelector.selectModel")
) {
return selectedModelLabel; return selectedModelLabel;
} }
return buildSelectedTriggerLabel(selectedModelLabel); return buildSelectedTriggerLabel(selectedModelLabel);
}, [selectedModelLabel]); }, [selectedModelLabel, t]);
useEffect(() => { useEffect(() => {
if (platformIsWeb) { if (platformIsWeb) {
@@ -713,7 +730,7 @@ export function CombinedModelSelector({
const sheetHeader = useMemo<SheetHeader>(() => { const sheetHeader = useMemo<SheetHeader>(() => {
if (view.kind === "all") { if (view.kind === "all") {
return { title: "Select provider" }; return { title: t("modelSelector.title") };
} }
const ProviderIconForView = getProviderIcon(view.providerId); const ProviderIconForView = getProviderIcon(view.providerId);
const headerActions = ( const headerActions = (
@@ -723,7 +740,9 @@ export function CombinedModelSelector({
hitSlop={8} hitSlop={8}
style={iconButtonStyle} style={iconButtonStyle}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Open ${view.providerLabel} settings`} accessibilityLabel={t("modelSelector.openProviderSettings", {
provider: view.providerLabel,
})}
testID={`selector-header-settings-${view.providerId}`} testID={`selector-header-settings-${view.providerId}`}
> >
<Settings <Settings
@@ -742,7 +761,7 @@ export function CombinedModelSelector({
search: { search: {
onChange: handleSearchQueryChange, onChange: handleSearchQueryChange,
resetKey: `${view.providerId}:${searchResetKey}`, resetKey: `${view.providerId}:${searchResetKey}`,
placeholder: "Search models...", placeholder: t("modelSelector.searchPlaceholder"),
autoFocus: platformIsWeb, autoFocus: platformIsWeb,
testID: "model-search-input", testID: "model-search-input",
}, },
@@ -757,6 +776,7 @@ export function CombinedModelSelector({
handleBackToAll, handleBackToAll,
handleSearchQueryChange, handleSearchQueryChange,
searchResetKey, searchResetKey,
t,
theme.iconSize.md, theme.iconSize.md,
theme.iconSize.sm, theme.iconSize.sm,
theme.colors.foreground, theme.colors.foreground,
@@ -771,7 +791,7 @@ export function CombinedModelSelector({
onPress={handleTriggerPress} onPress={handleTriggerPress}
style={triggerStyle} style={triggerStyle}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Select model (${selectedModelLabel})`} accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
testID="combined-model-selector" testID="combined-model-selector"
> >
{renderTrigger ? ( {renderTrigger ? (
@@ -823,7 +843,7 @@ export function CombinedModelSelector({
) : ( ) : (
<View style={styles.sheetLoadingState}> <View style={styles.sheetLoadingState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> <ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={styles.sheetLoadingText}>Loading model selector…</Text> <Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
</View> </View>
)} )}
</Combobox> </Combobox>
+11 -6
View File
@@ -8,6 +8,7 @@ import {
type PressableStateCallbackType, type PressableStateCallbackType,
} from "react-native"; } from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Home, Plus, Settings } from "lucide-react-native"; import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center"; import { useCommandCenter } from "@/hooks/use-command-center";
@@ -202,6 +203,7 @@ interface CommandCenterAgentRowContentProps {
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) { function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const titleStyle = useMemo( const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }], () => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground], [theme.colors.foreground],
@@ -222,7 +224,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
</View> </View>
<View style={styles.textContent}> <View style={styles.textContent}>
<Text style={titleStyle} numberOfLines={1}> <Text style={titleStyle} numberOfLines={1}>
{agent.title || "New agent"} {agent.title || t("shell.commandCenter.newAgent")}
</Text> </Text>
<Text style={subtitleStyle} numberOfLines={1}> <Text style={subtitleStyle} numberOfLines={1}>
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)} {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
@@ -256,10 +258,12 @@ function AgentItemsSection({
sectionDividerStyle, sectionDividerStyle,
sectionLabelStyle, sectionLabelStyle,
}: AgentItemsSectionProps) { }: AgentItemsSectionProps) {
const { t } = useTranslation();
return ( return (
<> <>
{actionItemsLength > 0 ? <View style={sectionDividerStyle} /> : null} {actionItemsLength > 0 ? <View style={sectionDividerStyle} /> : null}
<Text style={sectionLabelStyle}>Agents</Text> <Text style={sectionLabelStyle}>{t("shell.commandCenter.agents")}</Text>
{agentItems.map((item, index) => { {agentItems.map((item, index) => {
const rowIndex = actionItemsLength + index; const rowIndex = actionItemsLength + index;
const agent = item.agent; const agent = item.agent;
@@ -283,6 +287,7 @@ function AgentItemsSection({
export function CommandCenter() { export function CommandCenter() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const { const {
open, open,
inputRef, inputRef,
@@ -442,12 +447,12 @@ export function CommandCenter() {
const resultList = const resultList =
items.length === 0 ? ( items.length === 0 ? (
<Text style={emptyTextStyle}>No matches</Text> <Text style={emptyTextStyle}>{t("shell.commandCenter.noMatches")}</Text>
) : ( ) : (
<> <>
{actionItems.length > 0 ? ( {actionItems.length > 0 ? (
<> <>
<Text style={sectionLabelStyle}>Actions</Text> <Text style={sectionLabelStyle}>{t("shell.commandCenter.actions")}</Text>
{actionItems.map((item, index) => ( {actionItems.map((item, index) => (
<CommandCenterActionRow <CommandCenterActionRow
key={`action:${item.action.id}`} key={`action:${item.action.id}`}
@@ -501,7 +506,7 @@ export function CommandCenter() {
onChangeText={setQuery} onChangeText={setQuery}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
onSubmitEditing={handleSubmitEditing} onSubmitEditing={handleSubmitEditing}
placeholder="Type a command or search agents..." placeholder={t("shell.commandCenter.placeholder")}
style={inputStyle} style={inputStyle}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@@ -534,7 +539,7 @@ export function CommandCenter() {
ref={inputRef} ref={inputRef}
value={query} value={query}
onChangeText={setQuery} onChangeText={setQuery}
placeholder="Type a command or search agents..." placeholder={t("shell.commandCenter.placeholder")}
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle} style={inputStyle}
autoCapitalize="none" autoCapitalize="none"
@@ -1,6 +1,7 @@
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import Svg, { Circle } from "react-native-svg"; import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface ContextWindowMeterProps { interface ContextWindowMeterProps {
@@ -81,6 +82,7 @@ export function ContextWindowMeter({
showPercentage = false, showPercentage = false,
}: ContextWindowMeterProps) { }: ContextWindowMeterProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const percentage = getUsagePercentage(maxTokens, usedTokens); const percentage = getUsagePercentage(maxTokens, usedTokens);
if (percentage === null) { if (percentage === null) {
@@ -106,7 +108,9 @@ export function ContextWindowMeter({
<Pressable <Pressable
style={containerStyle} style={containerStyle}
accessibilityRole="image" accessibilityRole="image"
accessibilityLabel={`Context window ${roundedPercentage}% used`} accessibilityLabel={t("contextWindow.accessibility", {
percentage: roundedPercentage,
})}
> >
<Svg <Svg
width={svgSize} width={svgSize}
@@ -143,13 +147,20 @@ export function ContextWindowMeter({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipContent}> <View style={styles.tooltipContent}>
<Text style={styles.tooltipTitle}>Context window</Text> <Text style={styles.tooltipTitle}>{t("contextWindow.title")}</Text>
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text> <Text style={styles.tooltipText}>
<Text {t("contextWindow.used", { percentage: roundedPercentage })}
style={styles.tooltipDetail} </Text>
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text> <Text style={styles.tooltipDetail}>
{t("contextWindow.tokens", {
used: formatTokenCount(usedTokens),
max: formatTokenCount(maxTokens),
})}
</Text>
{formattedSessionCost ? ( {formattedSessionCost ? (
<Text style={styles.tooltipDetail}>{`Session cost ${formattedSessionCost}`}</Text> <Text style={styles.tooltipDetail}>
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
</Text>
) : null} ) : null}
</View> </View>
</TooltipContent> </TooltipContent>
@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { View, Text, Pressable, ActivityIndicator } from "react-native"; import { View, Text, Pressable, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native"; import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { VolumeMeter } from "./volume-meter"; import { VolumeMeter } from "./volume-meter";
import { FOOTER_HEIGHT } from "@/constants/layout"; import { FOOTER_HEIGHT } from "@/constants/layout";
import type { DictationStatus } from "@/hooks/use-dictation"; import type { DictationStatus } from "@/hooks/use-dictation";
@@ -43,6 +44,7 @@ export function DictationControls({
disabled = false, disabled = false,
}: DictationControlsProps) { }: DictationControlsProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const isFailed = status === "failed"; const isFailed = status === "failed";
const showActiveState = isRecording || isProcessing || isFailed; const showActiveState = isRecording || isProcessing || isFailed;
const actionsDisabled = isProcessing; const actionsDisabled = isProcessing;
@@ -71,7 +73,7 @@ export function DictationControls({
onPress={onStart} onPress={onStart}
disabled={disabled} disabled={disabled}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Start voice dictation" accessibilityLabel={t("message.dictation.start")}
style={micButtonStyle} style={micButtonStyle}
> >
<Mic size={theme.iconSize.md} color={theme.colors.foreground} /> <Mic size={theme.iconSize.md} color={theme.colors.foreground} />
@@ -89,7 +91,7 @@ export function DictationControls({
<Pressable <Pressable
onPress={handleCancel} onPress={handleCancel}
disabled={actionsDisabled && !isFailed} disabled={actionsDisabled && !isFailed}
accessibilityLabel="Cancel dictation" accessibilityLabel={t("message.dictation.cancel")}
style={cancelButtonStyle} style={cancelButtonStyle}
> >
<X size={theme.iconSize.sm} color={theme.colors.foreground} /> <X size={theme.iconSize.sm} color={theme.colors.foreground} />
@@ -102,7 +104,7 @@ export function DictationControls({
{!actionsDisabled && isFailed ? ( {!actionsDisabled && isFailed ? (
<Pressable <Pressable
onPress={onRetry} onPress={onRetry}
accessibilityLabel="Retry dictation" accessibilityLabel={t("message.dictation.retry")}
style={ACTION_CONFIRM_STYLE} style={ACTION_CONFIRM_STYLE}
> >
<RefreshCcw size={theme.iconSize.sm} color={theme.colors.surface0} /> <RefreshCcw size={theme.iconSize.sm} color={theme.colors.surface0} />
@@ -112,14 +114,14 @@ export function DictationControls({
<> <>
<Pressable <Pressable
onPress={onAccept} onPress={onAccept}
accessibilityLabel="Insert transcription" accessibilityLabel={t("message.dictation.insert")}
style={ACTION_SECONDARY_STYLE} style={ACTION_SECONDARY_STYLE}
> >
<Check size={theme.iconSize.sm} color={theme.colors.foreground} /> <Check size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable> </Pressable>
<Pressable <Pressable
onPress={onAcceptAndSend} onPress={onAcceptAndSend}
accessibilityLabel="Insert transcription and send" accessibilityLabel={t("message.dictation.insertAndSend")}
style={ACTION_CONFIRM_STYLE} style={ACTION_CONFIRM_STYLE}
> >
<ArrowUp size={theme.iconSize.sm} color={theme.colors.surface0} /> <ArrowUp size={theme.iconSize.sm} color={theme.colors.surface0} />
@@ -149,6 +151,7 @@ export function DictationOverlay({
onDiscard, onDiscard,
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & { errorText?: string }) { }: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & { errorText?: string }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const isFailed = status === "failed"; const isFailed = status === "failed";
const showActiveState = isRecording || isProcessing || isFailed; const showActiveState = isRecording || isProcessing || isFailed;
const actionsDisabled = isProcessing; const actionsDisabled = isProcessing;
@@ -189,7 +192,7 @@ export function DictationOverlay({
onPress={handleCancel} onPress={handleCancel}
disabled={actionsDisabled && !isFailed} disabled={actionsDisabled && !isFailed}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Cancel dictation" accessibilityLabel={t("message.dictation.cancel")}
style={overlayCancelButtonStyle} style={overlayCancelButtonStyle}
> >
<X size={theme.iconSize.lg} color={theme.colors.accentForeground} strokeWidth={2.5} /> <X size={theme.iconSize.lg} color={theme.colors.accentForeground} strokeWidth={2.5} />
@@ -208,7 +211,9 @@ export function DictationOverlay({
</View> </View>
{isFailed ? ( {isFailed ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}> <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> </Text>
) : null} ) : null}
</View> </View>
@@ -223,7 +228,7 @@ export function DictationOverlay({
<Pressable <Pressable
onPress={onRetry} onPress={onRetry}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Retry dictation" accessibilityLabel={t("message.dictation.retry")}
style={overlayRetryButtonStyle} style={overlayRetryButtonStyle}
> >
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} /> <RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
@@ -234,7 +239,7 @@ export function DictationOverlay({
<Pressable <Pressable
onPress={onAccept} onPress={onAccept}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Insert transcription" accessibilityLabel={t("message.dictation.insert")}
style={OVERLAY_ACCEPT_BUTTON_STYLE} style={OVERLAY_ACCEPT_BUTTON_STYLE}
> >
<Pencil <Pencil
@@ -246,7 +251,7 @@ export function DictationOverlay({
<Pressable <Pressable
onPress={onAcceptAndSend} onPress={onAcceptAndSend}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Insert transcription and send" accessibilityLabel={t("message.dictation.insertAndSend")}
style={overlayConfirmButtonStyle} style={overlayConfirmButtonStyle}
> >
<ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} /> <ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
+5 -2
View File
@@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { View, Text, ScrollView as RNScrollView } from "react-native"; import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
@@ -120,10 +121,12 @@ function DiffSegment({
export function DiffViewer({ export function DiffViewer({
diffLines, diffLines,
maxHeight, maxHeight,
emptyLabel = "No changes to display", emptyLabel,
fillAvailableHeight = false, fillAvailableHeight = false,
}: DiffViewerProps) { }: DiffViewerProps) {
const { t } = useTranslation();
const [scrollViewWidth, setScrollViewWidth] = React.useState(0); const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
const resolvedEmptyLabel = emptyLabel ?? t("diffViewer.empty");
const webScrollbarStyle = useWebScrollbarStyle(); const webScrollbarStyle = useWebScrollbarStyle();
const handleInnerLayout = React.useCallback( const handleInnerLayout = React.useCallback(
(e: { nativeEvent: { layout: { width: number } } }) => (e: { nativeEvent: { layout: { width: number } } }) =>
@@ -159,7 +162,7 @@ export function DiffViewer({
if (!diffLines.length) { if (!diffLines.length) {
return ( return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={styles.emptyText}>{emptyLabel}</Text> <Text style={styles.emptyText}>{resolvedEmptyLabel}</Text>
</View> </View>
); );
} }
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useMemo, useRef } from "react"; 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 { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -7,19 +9,20 @@ import { useDownloadStore, formatSpeed, formatEta, type Download } from "@/store
const AUTO_DISMISS_DELAY = 3000; const AUTO_DISMISS_DELAY = 3000;
function getDownloadStatusText(download: Download): string { function getDownloadStatusText(download: Download, t: TFunction): string {
if (download.status === "downloading") { if (download.status === "downloading") {
if (download.progress) { if (download.progress) {
return `${Math.round(download.progress.percent * 100)}% · ${formatSpeed(download.progress.speed)} · ${formatEta(download.progress.eta)}`; 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"; if (download.status === "complete") return t("common.states.downloadComplete");
return download.message ?? "Download failed"; return download.message ?? t("common.states.downloadFailed");
} }
export function DownloadToast() { export function DownloadToast() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const downloads = useDownloadStore((state) => state.downloads); const downloads = useDownloadStore((state) => state.downloads);
const activeDownloadId = useDownloadStore((state) => state.activeDownloadId); const activeDownloadId = useDownloadStore((state) => state.activeDownloadId);
@@ -78,7 +81,7 @@ export function DownloadToast() {
<Text style={styles.fileName} numberOfLines={1}> <Text style={styles.fileName} numberOfLines={1}>
{activeDownload.fileName} {activeDownload.fileName}
</Text> </Text>
<Text style={styles.status}>{getDownloadStatusText(activeDownload)}</Text> <Text style={styles.status}>{getDownloadStatusText(activeDownload, t)}</Text>
{activeDownload.status === "downloading" && activeDownload.progress && ( {activeDownload.status === "downloading" && activeDownload.progress && (
<View style={styles.progressBar}> <View style={styles.progressBar}>
<ProgressFill percent={activeDownload.progress.percent} /> <ProgressFill percent={activeDownload.progress.percent} />
@@ -12,6 +12,7 @@ import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-nativ
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { GitHubIcon } from "@/components/icons/github-icon"; import { GitHubIcon } from "@/components/icons/github-icon";
import { PrPane } from "@/git/pr-pane"; import { PrPane } from "@/git/pr-pane";
import { usePrPaneData } from "@/hooks/use-pr-pane-data"; import { usePrPaneData } from "@/hooks/use-pr-pane-data";
@@ -418,6 +419,7 @@ function SidebarContent({
onOpenFile, onOpenFile,
}: SidebarContentProps) { }: SidebarContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const padding = useWindowControlsPadding("explorerSidebar"); const padding = useWindowControlsPadding("explorerSidebar");
const canQueryPullRequest = isGit && Boolean(workspaceRoot); const canQueryPullRequest = isGit && Boolean(workspaceRoot);
const prPane = usePrPaneData({ const prPane = usePrPaneData({
@@ -448,7 +450,7 @@ function SidebarContent({
<ExplorerTabButton <ExplorerTabButton
tab="changes" tab="changes"
active={resolvedTab === "changes"} active={resolvedTab === "changes"}
label="Changes" label={t("workspace.tabs.explorer.changes")}
onTabPress={onTabPress} onTabPress={onTabPress}
testID="explorer-tab-changes" testID="explorer-tab-changes"
/> />
@@ -456,7 +458,7 @@ function SidebarContent({
<ExplorerTabButton <ExplorerTabButton
tab="files" tab="files"
active={resolvedTab === "files"} active={resolvedTab === "files"}
label="Files" label={t("workspace.tabs.explorer.files")}
onTabPress={onTabPress} onTabPress={onTabPress}
testID="explorer-tab-files" testID="explorer-tab-files"
/> />
@@ -3,6 +3,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated"; import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Upload } from "lucide-react-native"; import { Upload } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useFileDropZone } from "@/hooks/use-file-drop-zone"; import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "@/composer/types"; import type { ImageAttachment } from "@/composer/types";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
@@ -16,6 +17,7 @@ interface FileDropZoneProps {
const IS_WEB = isWeb; const IS_WEB = isWeb;
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) { export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { isDragging, containerRef } = useFileDropZone({ const { isDragging, containerRef } = useFileDropZone({
onFilesDropped, onFilesDropped,
@@ -58,7 +60,7 @@ export function FileDropZone({ children, onFilesDropped, disabled = false }: Fil
{/* Content */} {/* Content */}
<View style={styles.overlayContent}> <View style={styles.overlayContent}>
<Upload size={32} color={theme.colors.primary} /> <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> </View>
</Animated.View> </Animated.View>
</View> </View>
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObject } from "react"; import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObject } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
FlatList, FlatList,
@@ -45,10 +46,10 @@ import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [ const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "name", label: "Name" }, { value: "name" },
{ value: "modified", label: "Modified" }, { value: "modified" },
{ value: "size", label: "Size" }, { value: "size" },
]; ];
const INDENT_PER_LEVEL = 16; const INDENT_PER_LEVEL = 16;
@@ -115,6 +116,7 @@ function TreeRowItem({
onDownloadEntry, onDownloadEntry,
}: TreeRowItemProps) { }: TreeRowItemProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const isDirectory = entry.kind === "directory"; const isDirectory = entry.kind === "directory";
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
@@ -181,7 +183,7 @@ function TreeRowItem({
<View style={styles.contextMetaBlock}> <View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}> <View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}> <Text style={styles.contextMetaLabel} numberOfLines={1}>
Size {t("workspace.fileExplorer.context.size")}
</Text> </Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail"> <Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })} {formatFileSize({ size: entry.size })}
@@ -189,7 +191,7 @@ function TreeRowItem({
</View> </View>
<View style={styles.contextMetaRow}> <View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}> <Text style={styles.contextMetaLabel} numberOfLines={1}>
Modified {t("workspace.fileExplorer.context.modified")}
</Text> </Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail"> <Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))} {formatTimeAgo(new Date(entry.modifiedAt))}
@@ -198,11 +200,11 @@ function TreeRowItem({
</View> </View>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}> <DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
Copy path {t("workspace.fileExplorer.context.copyPath")}
</DropdownMenuItem> </DropdownMenuItem>
{entry.kind === "file" ? ( {entry.kind === "file" ? (
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}> <DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
Download {t("workspace.fileExplorer.context.download")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
</DropdownMenuContent> </DropdownMenuContent>
@@ -229,6 +231,7 @@ export function FileExplorerPane({
workspaceRoot, workspaceRoot,
onOpenFile, onOpenFile,
}: FileExplorerPaneProps) { }: FileExplorerPaneProps) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
@@ -395,7 +398,15 @@ export function FileExplorerPane({
void refetchExplorer(); void refetchExplorer();
}, [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( const treeRows = useMemo(
() => resolveTreeRows({ directories, expandedPaths, sortOption }), () => resolveTreeRows({ directories, expandedPaths, sortOption }),
@@ -453,7 +464,7 @@ export function FileExplorerPane({
if (!hasWorkspaceScope) { if (!hasWorkspaceScope) {
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
<Text style={styles.errorText}>Workspace is unavailable</Text> <Text style={styles.errorText}>{t("workspace.fileExplorer.states.unavailable")}</Text>
</View> </View>
); );
} }
@@ -503,6 +514,7 @@ interface FileExplorerPaneContentProps {
function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const { const {
error, error,
showInitialLoading, showInitialLoading,
@@ -529,11 +541,11 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
<View style={styles.errorActions}> <View style={styles.errorActions}>
{showBackFromError ? ( {showBackFromError ? (
<Pressable style={styles.retryButton} onPress={handleBackFromError}> <Pressable style={styles.retryButton} onPress={handleBackFromError}>
<Text style={styles.retryButtonText}>Back</Text> <Text style={styles.retryButtonText}>{t("workspace.fileExplorer.actions.back")}</Text>
</Pressable> </Pressable>
) : null} ) : null}
<Pressable style={styles.retryButton} onPress={handleRetry}> <Pressable style={styles.retryButton} onPress={handleRetry}>
<Text style={styles.retryButtonText}>Retry</Text> <Text style={styles.retryButtonText}>{t("workspace.fileExplorer.actions.retry")}</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
@@ -544,7 +556,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
<ActivityIndicator size="small" /> <ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading files…</Text> <Text style={styles.loadingText}>{t("workspace.fileExplorer.states.loading")}</Text>
</View> </View>
); );
} }
@@ -552,7 +564,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
if (treeRows.length === 0) { if (treeRows.length === 0) {
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
<Text style={styles.emptyText}>No files</Text> <Text style={styles.emptyText}>{t("workspace.fileExplorer.empty.noFiles")}</Text>
</View> </View>
); );
} }
@@ -570,7 +582,11 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
hitSlop={8} hitSlop={8}
style={iconButtonStyleProp} style={iconButtonStyleProp}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"} accessibilityLabel={
isRefreshFetching
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
> >
<View style={styles.refreshIcon}> <View style={styles.refreshIcon}>
{isRefreshFetching ? ( {isRefreshFetching ? (
@@ -704,8 +720,11 @@ function resolveShowInitialLoading({
); );
} }
function resolveCurrentSortLabel(sortOption: SortOption): string { function resolveCurrentSortLabel(
return SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name"; sortOption: SortOption,
labels: Record<SortOption, string>,
): string {
return labels[sortOption] ?? labels.name;
} }
function resolveTreeRows({ function resolveTreeRows({
+12 -6
View File
@@ -17,6 +17,7 @@ import {
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text"; import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
@@ -503,6 +504,7 @@ function FilePreviewBody({
imagePreviewUri, imagePreviewUri,
}: FilePreviewBodyProps) { }: FilePreviewBodyProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const filePath = location.path; const filePath = location.path;
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []); const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
@@ -562,7 +564,7 @@ function FilePreviewBody({
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
<ActivityIndicator size="small" /> <ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text> <Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View> </View>
); );
} }
@@ -570,7 +572,7 @@ function FilePreviewBody({
if (!preview) { if (!preview) {
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
<Text style={styles.emptyText}>No preview available</Text> <Text style={styles.emptyText}>{t("panels.file.noPreview")}</Text>
</View> </View>
); );
} }
@@ -659,7 +661,7 @@ function FilePreviewBody({
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
<ActivityIndicator size="small" /> <ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text> <Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View> </View>
); );
} }
@@ -689,7 +691,7 @@ function FilePreviewBody({
return ( return (
<View style={styles.centerState}> <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> <Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View> </View>
); );
@@ -704,6 +706,7 @@ export function FilePane({
workspaceRoot: string; workspaceRoot: string;
location: WorkspaceFileLocation; location: WorkspaceFileLocation;
}) { }) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
@@ -726,7 +729,10 @@ export function FilePane({
enabled: Boolean(client && readTarget), enabled: Boolean(client && readTarget),
queryFn: async () => { queryFn: async () => {
if (!client || !readTarget) { 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 { try {
const file = await client.readFile(readTarget.cwd, readTarget.path); const file = await client.readFile(readTarget.cwd, readTarget.path);
@@ -740,7 +746,7 @@ export function FilePane({
return { return {
file: null, file: null,
imageAttachment: null, imageAttachment: null,
error: error instanceof Error ? error.message : "Failed to load file", error: error instanceof Error ? error.message : t("panels.file.failedToLoad"),
}; };
} }
}, },
@@ -1,4 +1,5 @@
import { useCallback, type ReactNode } from "react"; import { useCallback, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pressable } from "react-native"; import { Pressable } from "react-native";
import { router } from "expo-router"; import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -19,6 +20,7 @@ function goBack(): void {
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) { export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
if (onBack) { if (onBack) {
onBack(); onBack();
@@ -35,7 +37,7 @@ export function BackHeader({ title, titleAccessory, rightContent, onBack }: Back
onPress={handleBack} onPress={handleBack}
style={styles.backButton} style={styles.backButton}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Back" accessibilityLabel={t("common.actions.back")}
> >
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} /> <ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
@@ -1,4 +1,5 @@
import { useCallback, useMemo, type ReactNode } from "react"; import { useCallback, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { View, type StyleProp, type ViewStyle } from "react-native"; import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native"; import { PanelLeft } from "lucide-react-native";
@@ -48,6 +49,7 @@ export function SidebarMenuToggle({
nativeID = "menu-button", nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) { }: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile })); const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout); const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
@@ -68,7 +70,7 @@ export function SidebarMenuToggle({
return ( return (
<HeaderToggleButton <HeaderToggleButton
onPress={handlePress} onPress={handlePress}
tooltipLabel="Toggle sidebar" tooltipLabel={t("shell.menu.toggleSidebar")}
tooltipKeys={toggleShortcutKeys} tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide} tooltipSide={tooltipSide}
testID={testID} testID={testID}
@@ -76,7 +78,7 @@ export function SidebarMenuToggle({
style={style} style={style}
accessible accessible
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"} accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState} accessibilityState={accessibilityState}
> >
{isMobile ? ( {isMobile ? (
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
import { MarkdownTextSpan } from "@/components/markdown-text"; import { MarkdownTextSpan } from "@/components/markdown-text";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { Check, Copy } from "lucide-react-native"; import { Check, Copy } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { HighlightToken } from "@getpaseo/highlight"; import type { HighlightToken } from "@getpaseo/highlight";
import { isNative, isWeb } from "@/constants/platform"; import { isNative, isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -153,6 +154,7 @@ interface CopyButtonProps {
const COPIED_RESET_MS = 1500; const COPIED_RESET_MS = 1500;
const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) { const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null); const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -189,7 +191,7 @@ const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButt
style={wrapperStyle} style={wrapperStyle}
pointerEvents={visible ? "auto" : "none"} pointerEvents={visible ? "auto" : "none"}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={copied ? "Copied" : "Copy code"} accessibilityLabel={copied ? t("message.actions.copied") : t("message.actions.copyCode")}
hitSlop={8} hitSlop={8}
> >
{({ hovered }) => { {({ hovered }) => {
@@ -1,5 +1,6 @@
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client"; import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { i18n } from "@/i18n/i18next";
export const PER_PROVIDER_LIMIT = 15; export const PER_PROVIDER_LIMIT = 15;
export const ALL_FILTER_VALUE = "__all__"; export const ALL_FILTER_VALUE = "__all__";
@@ -97,11 +98,15 @@ export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string
if (firstPromptPreview) { if (firstPromptPreview) {
return firstPromptPreview; return firstPromptPreview;
} }
return "Untitled session"; return i18n.t("importSession.preview.untitledSession");
} }
export function getPromptPreview(entry: FetchRecentProviderSessionEntry): string { 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 { export interface EmptyStateInputs {
@@ -132,10 +137,16 @@ export function computeEmptyState(input: EmptyStateInputs): {
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0; const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
if (isFilteredEmpty) { if (isFilteredEmpty) {
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider; 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) { 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") };
} }
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native"; import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { import type {
DaemonClient, DaemonClient,
FetchRecentProviderSessionEntry, FetchRecentProviderSessionEntry,
@@ -14,6 +15,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { i18n } from "@/i18n/i18next";
import { import {
aggregateSessionEntries, aggregateSessionEntries,
ALL_FILTER_VALUE, ALL_FILTER_VALUE,
@@ -63,8 +65,10 @@ function buildSessionsQueriesConfig(args: {
visible: boolean; visible: boolean;
client: RecentProviderSessionsClient | null; client: RecentProviderSessionsClient | null;
cwd: string | null | undefined; cwd: string | null | undefined;
hostDisconnectedMessage?: string;
}): SessionsQueryConfig[] { }): SessionsQueryConfig[] {
const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args; const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } =
args;
if (providersToFetch === null) return []; if (providersToFetch === null) return [];
const enabled = visible && Boolean(client); const enabled = visible && Boolean(client);
return providersToFetch.map((provider) => ({ return providersToFetch.map((provider) => ({
@@ -72,7 +76,7 @@ function buildSessionsQueriesConfig(args: {
enabled, enabled,
queryFn: async () => { queryFn: async () => {
if (!client) { if (!client) {
throw new Error("Host is not connected"); throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
} }
return await client.fetchRecentProviderSessions({ return await client.fetchRecentProviderSessions({
...(cwd ? { cwd } : {}), ...(cwd ? { cwd } : {}),
@@ -105,33 +109,36 @@ function SheetStatusMessages({
importErrored, importErrored,
}: SheetStatusMessagesProps) { }: SheetStatusMessagesProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
if (!isClientReady) { 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) { 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 ( return (
<> <>
{hasNoImportableProviders ? ( {hasNoImportableProviders ? (
<Text style={styles.statusText}>No importable providers are enabled.</Text> <Text style={styles.statusText}>{t("importSession.status.noProviders")}</Text>
) : null} ) : null}
{isLoadingSessions && !hasRows ? ( {isLoadingSessions && !hasRows ? (
<View style={styles.statusRow}> <View style={styles.statusRow}>
<LoadingSpinner color={theme.colors.foregroundMuted} /> <LoadingSpinner color={theme.colors.foregroundMuted} />
<Text style={styles.statusText}>Loading recent sessions...</Text> <Text style={styles.statusText}>{t("importSession.status.loading")}</Text>
</View> </View>
) : null} ) : null}
{allQueriesErrored ? ( {allQueriesErrored ? (
<Text style={styles.statusText}>Could not load recent sessions.</Text> <Text style={styles.statusText}>{t("importSession.status.failedAll")}</Text>
) : null} ) : null}
{!allQueriesErrored && erroredProviderLabels.length > 0 ? ( {!allQueriesErrored && erroredProviderLabels.length > 0 ? (
<Text style={styles.statusText}> <Text style={styles.statusText}>
Could not load sessions for {erroredProviderLabels.join(", ")}. {t("importSession.status.failedProviders", {
providers: erroredProviderLabels.join(", "),
})}
</Text> </Text>
) : null} ) : null}
{importErrored ? ( {importErrored ? (
<Text style={styles.statusText}>Could not import selected session.</Text> <Text style={styles.statusText}>{t("importSession.status.failedImport")}</Text>
) : null} ) : null}
</> </>
); );
@@ -139,6 +146,7 @@ function SheetStatusMessages({
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) { function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const pressableStyle = useCallback( const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [ ({ pressed }: PressableStateCallbackType) => [
styles.refreshButton, styles.refreshButton,
@@ -150,7 +158,7 @@ function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPre
<Pressable <Pressable
onPress={onPress} onPress={onPress}
disabled={isRefreshing} disabled={isRefreshing}
accessibilityLabel="Refresh sessions" accessibilityLabel={t("importSession.actions.refresh")}
accessibilityRole="button" accessibilityRole="button"
testID="import-session-refresh" testID="import-session-refresh"
style={pressableStyle} style={pressableStyle}
@@ -192,6 +200,7 @@ function ImportSessionSheetRow({
onImportSession: (entry: FetchRecentProviderSessionEntry) => void; onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const title = getSessionTitle(entry); const title = getSessionTitle(entry);
const promptPreview = getPromptPreview(entry); const promptPreview = getPromptPreview(entry);
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt)); const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
@@ -229,7 +238,9 @@ function ImportSessionSheetRow({
<Text style={styles.rowTitle} numberOfLines={1}> <Text style={styles.rowTitle} numberOfLines={1}>
{title} {title}
</Text> </Text>
<Text style={styles.rowMeta}>{importing ? "Importing..." : lastActivity}</Text> <Text style={styles.rowMeta}>
{importing ? t("importSession.row.importing") : lastActivity}
</Text>
</View> </View>
<Text style={styles.rowPreview} numberOfLines={2}> <Text style={styles.rowPreview} numberOfLines={2}>
{promptPreview} {promptPreview}
@@ -253,6 +264,7 @@ export function ImportSessionSheet({
onImportedAgent, onImportedAgent,
onImported, onImported,
}: ImportSessionSheetProps) { }: ImportSessionSheetProps) {
const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -284,8 +296,9 @@ export function ImportSessionSheet({
visible, visible,
client, client,
cwd, cwd,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}), }),
[providersToFetch, sessionsQueryRoot, visible, client, cwd], [providersToFetch, sessionsQueryRoot, visible, client, cwd, t],
); );
const queries = useQueries({ queries: queriesConfig }); const queries = useQueries({ queries: queriesConfig });
@@ -318,19 +331,20 @@ export function ImportSessionSheet({
const filterComboboxOptions = useMemo<ComboboxOption[]>( const filterComboboxOptions = useMemo<ComboboxOption[]>(
() => [ () => [
{ id: ALL_FILTER_VALUE, label: "All providers" }, { id: ALL_FILTER_VALUE, label: t("importSession.filters.all") },
...filterProviders.map((provider) => ({ ...filterProviders.map((provider) => ({
id: provider, id: provider,
label: providerLabelById.get(provider) ?? provider, label: providerLabelById.get(provider) ?? provider,
})), })),
], ],
[filterProviders, providerLabelById], [filterProviders, providerLabelById, t],
); );
const selectedProviderLabel = useMemo( const selectedProviderLabel = useMemo(
() => () =>
filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ?? "All providers", filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ??
[filterComboboxOptions, selectedProvider], t("importSession.filters.all"),
[filterComboboxOptions, selectedProvider, t],
); );
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []); const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
@@ -385,7 +399,7 @@ export function ImportSessionSheet({
const importMutation = useMutation({ const importMutation = useMutation({
mutationFn: async (entry: FetchRecentProviderSessionEntry) => { mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
if (!client) { if (!client) {
throw new Error("Host is not connected"); throw new Error(t("workspace.terminal.hostDisconnected"));
} }
if (!entry.cwd) { if (!entry.cwd) {
throw new Error("Session is missing a working directory"); throw new Error("Session is missing a working directory");
@@ -430,10 +444,10 @@ export function ImportSessionSheet({
const header = useMemo<SheetHeader>( const header = useMemo<SheetHeader>(
() => ({ () => ({
title: "Import session", title: t("importSession.title"),
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />, actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
}), }),
[isRefreshing, handleRefresh], [isRefreshing, handleRefresh, t],
); );
const isSnapshotUnsupported = !supportsSnapshot; const isSnapshotUnsupported = !supportsSnapshot;
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
@@ -9,9 +10,9 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts"; import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
const SNAP_POINTS: string[] = ["70%", "92%"]; const SNAP_POINTS: string[] = ["70%", "92%"];
const SHORTCUTS_HEADER: SheetHeader = { title: "Shortcuts" };
export function KeyboardShortcutsDialog() { export function KeyboardShortcutsDialog() {
const { t } = useTranslation();
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen); const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
@@ -23,10 +24,11 @@ export function KeyboardShortcutsDialog() {
); );
const handleClose = useCallback(() => setOpen(false), [setOpen]); const handleClose = useCallback(() => setOpen(false), [setOpen]);
const header = useMemo<SheetHeader>(() => ({ title: t("settings.shortcuts.dialogTitle") }), [t]);
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
header={SHORTCUTS_HEADER} header={header}
visible={open} visible={open}
onClose={handleClose} onClose={handleClose}
testID="keyboard-shortcuts-dialog" testID="keyboard-shortcuts-dialog"
@@ -35,13 +37,15 @@ export function KeyboardShortcutsDialog() {
<View testID="keyboard-shortcuts-dialog-content" style={styles.content}> <View testID="keyboard-shortcuts-dialog-content" style={styles.content}>
{sections.map((section) => ( {sections.map((section) => (
<View key={section.title} style={styles.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}> <View style={styles.rows}>
{section.rows.map((row) => ( {section.rows.map((row) => (
<View key={row.id} style={styles.row}> <View key={row.id} style={styles.row}>
<View style={styles.rowText}> <View style={styles.rowText}>
<Text style={styles.rowLabel}>{row.label}</Text> <Text style={styles.rowLabel}>{t(row.labelKey)}</Text>
{row.note ? <Text style={styles.rowNote}>{row.note}</Text> : null} {row.note ? (
<Text style={styles.rowNote}>{row.noteKey ? t(row.noteKey) : row.note}</Text>
) : null}
</View> </View>
<Shortcut keys={row.keys} style={styles.rowShortcut} /> <Shortcut keys={row.keys} style={styles.rowShortcut} />
</View> </View>
+53 -12
View File
@@ -1,5 +1,6 @@
import { router, usePathname } from "expo-router"; import { router, usePathname } from "expo-router";
import { FolderPlus, Home, MessagesSquare, Plus, Search, Settings, X } from "lucide-react-native"; import { FolderPlus, Home, MessagesSquare, Plus, Search, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { import {
type Dispatch, type Dispatch,
memo, memo,
@@ -104,6 +105,7 @@ interface SidebarSharedProps {
handleOpenProject: () => void; handleOpenProject: () => void;
handleHome: () => void; handleHome: () => void;
handleSettings: () => void; handleSettings: () => void;
labels: SidebarLabels;
renderHostOption: (input: { renderHostOption: (input: {
option: ComboboxOption; option: ComboboxOption;
selected: boolean; selected: boolean;
@@ -112,6 +114,16 @@ interface SidebarSharedProps {
}) => ReactElement; }) => ReactElement;
} }
interface SidebarLabels {
addProject: string;
home: string;
settings: string;
switchHost: string;
searchHosts: string;
sessions: string;
closeSidebar: string;
}
interface MobileSidebarProps extends SidebarSharedProps { interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number; insetsTop: number;
insetsBottom: number; insetsBottom: number;
@@ -132,6 +144,7 @@ export const LeftSidebar = memo(function LeftSidebar({
void _selectedAgentId; void _selectedAgentId;
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
const isOpen = usePanelStore((state) => const isOpen = usePanelStore((state) =>
@@ -146,10 +159,10 @@ export const LeftSidebar = memo(function LeftSidebar({
); );
const activeServerId = activeDaemon?.serverId ?? null; const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => { const activeHostLabel = useMemo(() => {
if (!activeDaemon) return "No host"; if (!activeDaemon) return t("sidebar.host.noHost");
const trimmed = activeDaemon.label?.trim(); const trimmed = activeDaemon.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId; return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
}, [activeDaemon]); }, [activeDaemon, t]);
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? ""); const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
const activeHostStatus = activeServerId const activeHostStatus = activeServerId
? (activeHostSnapshot?.connectionStatus ?? "connecting") ? (activeHostSnapshot?.connectionStatus ?? "connecting")
@@ -271,6 +284,19 @@ export const LeftSidebar = memo(function LeftSidebar({
[pathname], [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 = { const sharedProps = {
theme, theme,
activeServerId, activeServerId,
@@ -291,6 +317,7 @@ export const LeftSidebar = memo(function LeftSidebar({
handleRefresh, handleRefresh,
handleHostSelect, handleHostSelect,
renderHostOption, renderHostOption,
labels,
}; };
if (isCompactLayout) { if (isCompactLayout) {
@@ -425,12 +452,14 @@ function FooterIconButton({
function AddProjectTooltipContent({ function AddProjectTooltipContent({
newAgentKeys, newAgentKeys,
label,
}: { }: {
newAgentKeys: ReturnType<typeof useShortcutKeys>; newAgentKeys: ReturnType<typeof useShortcutKeys>;
label: string;
}) { }) {
return ( return (
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text> <Text style={styles.tooltipText}>{label}</Text>
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null} {newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
</View> </View>
); );
@@ -465,6 +494,7 @@ function SidebarFooter({
handleOpenProject, handleOpenProject,
handleHome, handleHome,
handleSettings, handleSettings,
labels,
}: { }: {
theme: SidebarTheme; theme: SidebarTheme;
activeServerId: string | null; activeServerId: string | null;
@@ -479,6 +509,13 @@ function SidebarFooter({
handleOpenProject: () => void; handleOpenProject: () => void;
handleHome: () => void; handleHome: () => void;
handleSettings: () => void; handleSettings: () => void;
labels: {
addProject: string;
home: string;
settings: string;
switchHost: string;
searchHosts: string;
};
}) { }) {
const newAgentKeys = useShortcutKeys("new-agent"); const newAgentKeys = useShortcutKeys("new-agent");
return ( return (
@@ -498,26 +535,26 @@ function SidebarFooter({
<FooterIconButton <FooterIconButton
onPress={handleOpenProject} onPress={handleOpenProject}
testID="sidebar-add-project" testID="sidebar-add-project"
accessibilityLabel="Add project" accessibilityLabel={labels.addProject}
icon={FolderPlus} icon={FolderPlus}
theme={theme} theme={theme}
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<AddProjectTooltipContent newAgentKeys={newAgentKeys} /> <AddProjectTooltipContent newAgentKeys={newAgentKeys} label={labels.addProject} />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<FooterIconButton <FooterIconButton
onPress={handleHome} onPress={handleHome}
testID="sidebar-home" testID="sidebar-home"
accessibilityLabel="Home" accessibilityLabel={labels.home}
icon={Home} icon={Home}
theme={theme} theme={theme}
/> />
<FooterIconButton <FooterIconButton
onPress={handleSettings} onPress={handleSettings}
testID="sidebar-settings" testID="sidebar-settings"
accessibilityLabel="Settings" accessibilityLabel={labels.settings}
icon={Settings} icon={Settings}
theme={theme} theme={theme}
/> />
@@ -528,8 +565,8 @@ function SidebarFooter({
onSelect={handleHostSelect} onSelect={handleHostSelect}
renderOption={renderHostOption} renderOption={renderHostOption}
searchable={false} searchable={false}
title="Switch host" title={labels.switchHost}
searchPlaceholder="Search hosts..." searchPlaceholder={labels.searchHosts}
desktopMinWidth={280} desktopMinWidth={280}
open={isHostPickerOpen} open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen} onOpenChange={setIsHostPickerOpen}
@@ -563,6 +600,7 @@ function MobileSidebar({
handleOpenProject, handleOpenProject,
handleHome, handleHome,
handleSettings, handleSettings,
labels,
insetsTop, insetsTop,
insetsBottom, insetsBottom,
isOpen, isOpen,
@@ -748,7 +786,7 @@ function MobileSidebar({
<View style={styles.sidebarHeaderRow}> <View style={styles.sidebarHeaderRow}>
<SidebarHeaderRow <SidebarHeaderRow
icon={MessagesSquare} icon={MessagesSquare}
label="Sessions" label={labels.sessions}
onPress={handleViewMore} onPress={handleViewMore}
isActive={isSessionsActive} isActive={isSessionsActive}
testID="sidebar-sessions" testID="sidebar-sessions"
@@ -765,7 +803,7 @@ function MobileSidebar({
nativeID="sidebar-close" nativeID="sidebar-close"
accessible accessible
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Close sidebar" accessibilityLabel={labels.closeSidebar}
hitSlop={8} hitSlop={8}
> >
{({ hovered, pressed }) => ( {({ hovered, pressed }) => (
@@ -810,6 +848,7 @@ function MobileSidebar({
handleOpenProject={handleOpenProject} handleOpenProject={handleOpenProject}
handleHome={handleHome} handleHome={handleHome}
handleSettings={handleSettings} handleSettings={handleSettings}
labels={labels}
/> />
</View> </View>
</Animated.View> </Animated.View>
@@ -842,6 +881,7 @@ function DesktopSidebar({
handleOpenProject, handleOpenProject,
handleHome, handleHome,
handleSettings, handleSettings,
labels,
insetsTop, insetsTop,
isOpen, isOpen,
handleViewMore, handleViewMore,
@@ -919,7 +959,7 @@ function DesktopSidebar({
<View style={styles.sidebarHeaderRow}> <View style={styles.sidebarHeaderRow}>
<SidebarHeaderRow <SidebarHeaderRow
icon={MessagesSquare} icon={MessagesSquare}
label="Sessions" label={labels.sessions}
onPress={handleViewMore} onPress={handleViewMore}
isActive={isSessionsActive} isActive={isSessionsActive}
testID="sidebar-sessions" testID="sidebar-sessions"
@@ -963,6 +1003,7 @@ function DesktopSidebar({
handleOpenProject={handleOpenProject} handleOpenProject={handleOpenProject}
handleHome={handleHome} handleHome={handleHome}
handleSettings={handleSettings} handleSettings={handleSettings}
labels={labels}
/> />
{/* Resize handle - absolutely positioned over right border */} {/* Resize handle - absolutely positioned over right border */}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import { getCompactionMarkerLabel } from "./message-compaction-label"; import { getCompactionMarkerLabel } from "./message-compaction-label";
describe("getCompactionMarkerLabel", () => { describe("getCompactionMarkerLabel", () => {
@@ -16,4 +17,13 @@ describe("getCompactionMarkerLabel", () => {
); );
expect(getCompactionMarkerLabel({ status: "completed" })).toBe("Context compacted"); 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");
}
});
}); });
@@ -1,3 +1,5 @@
import { i18n } from "@/i18n/i18next";
export interface CompactionMarkerLabelInput { export interface CompactionMarkerLabelInput {
status: "loading" | "completed"; status: "loading" | "completed";
trigger?: "auto" | "manual"; trigger?: "auto" | "manual";
@@ -9,9 +11,13 @@ export function getCompactionMarkerLabel({
trigger, trigger,
preTokens, preTokens,
}: CompactionMarkerLabelInput): string { }: CompactionMarkerLabelInput): string {
if (status === "loading") return "Compacting..."; if (status === "loading") return i18n.t("message.compaction.loading");
if (trigger === "auto") return "Context automatically compacted"; if (trigger === "auto") return i18n.t("message.compaction.auto");
if (trigger === "manual") return "Context manually compacted"; if (trigger === "manual") return i18n.t("message.compaction.manual");
if (preTokens) return `Context compacted (${Math.round(preTokens / 1000)}K tokens)`; if (preTokens) {
return "Context compacted"; return i18n.t("message.compaction.withTokens", {
tokens: Math.round(preTokens / 1000),
});
}
return i18n.t("message.compaction.completed");
} }
+45 -18
View File
@@ -10,6 +10,7 @@ import {
ViewStyle, ViewStyle,
type TextStyle, type TextStyle,
} from "react-native"; } from "react-native";
import { useTranslation } from "react-i18next";
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text"; import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import * as React from "react"; import * as React from "react";
@@ -448,18 +449,23 @@ function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAtta
return <Image source={imageSource} style={userMessageStylesheet.imageThumbnail} />; 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) { switch (attachment.type) {
case "review": { case "review": {
const count = attachment.comments.length; 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": case "github_pr":
return `PR #${attachment.number}`; return `PR #${attachment.number}`;
case "github_issue": case "github_issue":
return `Issue #${attachment.number}`; return `Issue #${attachment.number}`;
case "text": case "text":
return attachment.title ?? "Text attachment"; return attachment.title ?? t("message.attachments.textAttachment");
default: default:
return ""; return "";
} }
@@ -480,6 +486,7 @@ export const UserMessage = memo(function UserMessage({
disableOuterSpacing, disableOuterSpacing,
}: UserMessageProps) { }: UserMessageProps) {
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0; const hasText = message.trim().length > 0;
@@ -562,7 +569,7 @@ export const UserMessage = memo(function UserMessage({
style={userMessageStylesheet.structuredAttachmentPill} style={userMessageStylesheet.structuredAttachmentPill}
> >
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}> <Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}>
{getUserMessageAttachmentLabel(attachment)} {getUserMessageAttachmentLabel(attachment, t)}
</Text> </Text>
</View> </View>
))} ))}
@@ -588,7 +595,7 @@ export const UserMessage = memo(function UserMessage({
<TurnCopyButton <TurnCopyButton
getContent={getMessageContent} getContent={getMessageContent}
containerStyle={userMessageStylesheet.copyButton} containerStyle={userMessageStylesheet.copyButton}
accessibilityLabel="Copy message" accessibilityLabel={t("message.actions.copyMessage")}
/> />
</View> </View>
) : null} ) : null}
@@ -862,6 +869,7 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
const handleImageError = useCallback(() => { const handleImageError = useCallback(() => {
setLoadState({ status: "error" }); setLoadState({ status: "error" });
}, []); }, []);
const { t } = useTranslation();
const surfaceStyle = useMemo<StyleProp<ViewStyle>>( const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
() => [ () => [
assistantMessageStylesheet.imageSurface, assistantMessageStylesheet.imageSurface,
@@ -887,7 +895,9 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
<View style={stateSurfaceStyle}> <View style={stateSurfaceStyle}>
{loadState.status === "loading" ? <ActivityIndicator size="small" /> : null} {loadState.status === "loading" ? <ActivityIndicator size="small" /> : null}
{loadState.status === "error" ? ( {loadState.status === "error" ? (
<Text style={assistantMessageStylesheet.imageErrorText}>Image unavailable</Text> <Text style={assistantMessageStylesheet.imageErrorText}>
{t("message.attachments.imageUnavailable")}
</Text>
) : null} ) : null}
</View> </View>
</View> </View>
@@ -924,6 +934,7 @@ function AssistantMarkdownImage({
workspaceRoot?: string; workspaceRoot?: string;
serverId?: string; serverId?: string;
}) { }) {
const { t } = useTranslation();
const resolution = useMemo( const resolution = useMemo(
() => resolveAssistantImageSource({ source, workspaceRoot }), () => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot], [source, workspaceRoot],
@@ -953,7 +964,7 @@ function AssistantMarkdownImage({
const file = await client.readFile(resolution.cwd, resolution.path); const file = await client.readFile(resolution.cwd, resolution.path);
if (file.kind !== "image") { if (file.kind !== "image") {
throw new Error("Image preview unavailable."); throw new Error(t("message.attachments.imagePreviewUnavailable"));
} }
return await persistAttachmentFromBytes({ 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 ( return (
<View style={stateFrameStyle}> <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 (fileError instanceof Error) return fileError.message;
if (dataError instanceof Error) return dataError.message; if (dataError instanceof Error) return dataError.message;
return "Unable to load image preview."; return fallbackText;
} }
function getInlineCodeAutoLinkUrl( function getInlineCodeAutoLinkUrl(
@@ -1144,6 +1163,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
accessibilityLabel, accessibilityLabel,
copiedAccessibilityLabel, copiedAccessibilityLabel,
}: TurnCopyButtonProps) { }: TurnCopyButtonProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -1185,7 +1205,9 @@ export const TurnCopyButton = memo(function TurnCopyButton({
style={pressableStyle} style={pressableStyle}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={ accessibilityLabel={
copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn") copied
? (copiedAccessibilityLabel ?? t("message.actions.copied"))
: (accessibilityLabel ?? t("message.actions.copyTurn"))
} }
> >
{({ hovered }) => { {({ hovered }) => {
@@ -1966,6 +1988,7 @@ export const SpeakMessage = memo(function SpeakMessage({
timestamp: _timestamp, timestamp: _timestamp,
disableOuterSpacing, disableOuterSpacing,
}: SpeakMessageProps) { }: SpeakMessageProps) {
const { t } = useTranslation();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const containerStyle = useMemo( const containerStyle = useMemo(
() => [ () => [
@@ -1979,7 +2002,7 @@ export const SpeakMessage = memo(function SpeakMessage({
<View testID="speak-message" style={containerStyle}> <View testID="speak-message" style={containerStyle}>
<View style={speakMessageStylesheet.header}> <View style={speakMessageStylesheet.header}>
<ThemedMicVocal size={12} uniProps={foregroundMutedColorMapping} /> <ThemedMicVocal size={12} uniProps={foregroundMutedColorMapping} />
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text> <Text style={speakMessageStylesheet.headerLabel}>{t("message.speak.header")}</Text>
</View> </View>
<Text style={speakMessageStylesheet.text}>{message}</Text> <Text style={speakMessageStylesheet.text}>{message}</Text>
</View> </View>
@@ -2080,6 +2103,7 @@ export const ActivityLog = memo(function ActivityLog({
onArtifactClick, onArtifactClick,
disableOuterSpacing, disableOuterSpacing,
}: ActivityLogProps) { }: ActivityLogProps) {
const { t } = useTranslation();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
@@ -2149,7 +2173,9 @@ export const ActivityLog = memo(function ActivityLog({
</Text> </Text>
{metadata && ( {metadata && (
<View style={activityLogStylesheet.detailsRow}> <View style={activityLogStylesheet.detailsRow}>
<Text style={activityLogStylesheet.detailsText}>Details</Text> <Text style={activityLogStylesheet.detailsText}>
{t("message.activity.details")}
</Text>
{isExpanded ? ( {isExpanded ? (
<ChevronDown size={12} color="#71717a" /> <ChevronDown size={12} color="#71717a" />
) : ( ) : (
@@ -2306,6 +2332,7 @@ export const TodoListCard = memo(function TodoListCard({
items, items,
disableOuterSpacing, disableOuterSpacing,
}: TodoListCardProps) { }: TodoListCardProps) {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]); 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.detailsWrapper}>
<View style={todoListCardStylesheet.list}> <View style={todoListCardStylesheet.list}>
{items.length === 0 ? ( {items.length === 0 ? (
<Text style={todoListCardStylesheet.emptyText}>No tasks yet.</Text> <Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text>
) : ( ) : (
items.map((item) => ( items.map((item) => (
<TodoListItemRow key={item.text} text={item.text} completed={item.completed} /> <TodoListItemRow key={item.text} text={item.text} completed={item.completed} />
@@ -2328,11 +2355,11 @@ export const TodoListCard = memo(function TodoListCard({
</View> </View>
</View> </View>
); );
}, [items]); }, [items, t]);
return ( return (
<ExpandableBadge <ExpandableBadge
label="Tasks" label={t("message.todo.title")}
secondaryLabel={nextTask} secondaryLabel={nextTask}
icon={CheckSquare} icon={CheckSquare}
isExpanded={isExpanded} isExpanded={isExpanded}
@@ -2475,6 +2502,7 @@ function ExpandableBadgeLabelRow({
onOpenFileHoverIn, onOpenFileHoverIn,
onOpenFileHoverOut, onOpenFileHoverOut,
}: ExpandableBadgeLabelRowProps) { }: ExpandableBadgeLabelRowProps) {
const { t } = useTranslation();
return ( return (
<View <View
style={expandableBadgeStylesheet.labelRow} style={expandableBadgeStylesheet.labelRow}
@@ -2499,7 +2527,7 @@ function ExpandableBadgeLabelRow({
onHoverIn={onOpenFileHoverIn} onHoverIn={onOpenFileHoverIn}
onHoverOut={onOpenFileHoverOut} onHoverOut={onOpenFileHoverOut}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Open file" accessibilityLabel={t("message.actions.openFile")}
testID="tool-call-open-file" testID="tool-call-open-file"
style={expandableBadgeStylesheet.openFileButton} style={expandableBadgeStylesheet.openFileButton}
hitSlop={6} hitSlop={6}
@@ -3169,7 +3197,6 @@ export const ToolCall = memo(function ToolCall({
if (presentation.isPlan && effectiveDetail?.type === "plan") { if (presentation.isPlan && effectiveDetail?.type === "plan") {
return ( return (
<PlanCard <PlanCard
title="Plan"
text={effectiveDetail.text} text={effectiveDetail.text}
testID="timeline-plan-card" testID="timeline-plan-card"
disableOuterSpacing={disableOuterSpacing} disableOuterSpacing={disableOuterSpacing}
+21 -17
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Text, TextInput, View } from "react-native"; import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -12,7 +13,6 @@ import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adapt
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const; const FLEX_ONE_STYLE = { flex: 1 } as const;
const PAIR_LINK_HEADER: SheetHeader = { title: "Paste pairing link" };
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
helper: { helper: {
@@ -61,6 +61,7 @@ export interface PairLinkModalProps {
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) { export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts(); const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations(); const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
@@ -98,11 +99,11 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
if (isSaving) return; if (isSaving) return;
const raw = offerUrlRef.current.trim(); const raw = offerUrlRef.current.trim();
if (!raw) { if (!raw) {
setErrorMessage("Paste a pairing link (…/#offer=...)"); setErrorMessage(t("pairing.link.errors.required"));
return; return;
} }
if (!raw.includes("#offer=")) { if (!raw.includes("#offer=")) {
setErrorMessage("Link must include #offer=..."); setErrorMessage(t("pairing.link.errors.missingOffer"));
return; return;
} }
@@ -111,15 +112,15 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
const idx = raw.indexOf("#offer="); const idx = raw.indexOf("#offer=");
const encoded = raw.slice(idx + "#offer=".length).trim(); const encoded = raw.slice(idx + "#offer=".length).trim();
if (!encoded) { if (!encoded) {
throw new Error("Offer payload is empty"); throw new Error(t("pairing.link.errors.emptyOffer"));
} }
const payload = decodeOfferFragmentPayload(encoded); const payload = decodeOfferFragmentPayload(encoded);
return ConnectionOfferSchema.parse(payload); return ConnectionOfferSchema.parse(payload);
} catch (error) { } 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); setErrorMessage(message);
if (!isMobile) { if (!isMobile) {
Alert.alert("Pairing failed", message); Alert.alert(t("pairing.link.alert.failedTitle"), message);
} }
return null; return null;
} }
@@ -150,15 +151,16 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost }); onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
handleClose(); handleClose();
} catch (error) { } 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); setErrorMessage(message);
if (!isMobile) { if (!isMobile) {
Alert.alert("Pairing failed", message); Alert.alert(t("pairing.link.alert.failedTitle"), message);
} }
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]); }, [daemons, handleClose, isMobile, isSaving, onSaved, t, upsertDaemonFromOfferUrl]);
const handleChangeOfferUrl = useCallback((next: string) => { const handleChangeOfferUrl = useCallback((next: string) => {
offerUrlRef.current = next; offerUrlRef.current = next;
@@ -168,22 +170,24 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
void handleSave(); void handleSave();
}, [handleSave]); }, [handleSave]);
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.link.title") }), [t]);
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
header={PAIR_LINK_HEADER} header={header}
visible={visible} visible={visible}
onClose={handleClose} onClose={handleClose}
testID="pair-link-modal" 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}> <View style={styles.field}>
<Text style={styles.label}>Pairing link</Text> <Text style={styles.label}>{t("pairing.link.label")}</Text>
<AdaptiveTextInput <AdaptiveTextInput
ref={inputRef} ref={inputRef}
testID="pair-link-input" testID="pair-link-input"
nativeID="pair-link-input" nativeID="pair-link-input"
accessibilityLabel="pair-link-input" accessibilityLabel={t("pairing.link.label")}
onChangeText={handleChangeOfferUrl} onChangeText={handleChangeOfferUrl}
placeholder="https://app.paseo.sh/#offer=..." placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
@@ -204,9 +208,9 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
disabled={isSaving} disabled={isSaving}
testID="pair-link-cancel" testID="pair-link-cancel"
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Cancel" accessibilityLabel={t("pairing.link.actions.cancel")}
> >
Cancel {t("pairing.link.actions.cancel")}
</Button> </Button>
<Button <Button
style={FLEX_ONE_STYLE} style={FLEX_ONE_STYLE}
@@ -215,10 +219,10 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
disabled={isSaving} disabled={isSaving}
testID="pair-link-submit" testID="pair-link-submit"
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Pair" accessibilityLabel={t("pairing.link.actions.pair")}
leftIcon={pairIcon} leftIcon={pairIcon}
> >
{isSaving ? "Pairing..." : "Pair"} {isSaving ? t("pairing.link.actions.pairing") : t("pairing.link.actions.pair")}
</Button> </Button>
</View> </View>
</AdaptiveModalSheet> </AdaptiveModalSheet>
+5 -2
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 { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import Markdown, { type ASTNode } from "react-native-markdown-display"; import Markdown, { type ASTNode } from "react-native-markdown-display";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { createMarkdownStyles } from "@/styles/markdown-styles"; import { createMarkdownStyles } from "@/styles/markdown-styles";
import { getMarkdownListMarker } from "@/utils/markdown-list"; import { getMarkdownListMarker } from "@/utils/markdown-list";
@@ -194,7 +195,7 @@ function createPlanMarkdownRules() {
} }
export function PlanCard({ export function PlanCard({
title = "Plan", title,
description, description,
text, text,
footer, footer,
@@ -209,8 +210,10 @@ export function PlanCard({
testID?: string; testID?: string;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const markdownStyles = createMarkdownStyles(theme); const markdownStyles = createMarkdownStyles(theme);
const markdownRules = createPlanMarkdownRules(); const markdownRules = createPlanMarkdownRules();
const resolvedTitle = title ?? t("agentStream.permission.plan");
const containerStyle = useMemo( const containerStyle = useMemo(
() => [ () => [
@@ -234,7 +237,7 @@ export function PlanCard({
return ( return (
<View testID={testID} style={containerStyle}> <View testID={testID} style={containerStyle}>
<Text style={titleStyle}>{title}</Text> <Text style={titleStyle}>{resolvedTitle}</Text>
{description ? <Text style={descriptionStyle}>{description}</Text> : null} {description ? <Text style={descriptionStyle}>{description}</Text> : null}
<Markdown style={markdownStyles} rules={markdownRules}> <Markdown style={markdownStyles} rules={markdownRules}>
{text} {text}
@@ -11,6 +11,7 @@ import {
import { Folder } from "lucide-react-native"; import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
@@ -60,6 +61,7 @@ function PathRow({ path, active, onSelect }: PathRowProps) {
export function ProjectPickerModal() { export function ProjectPickerModal() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const serverId = useActiveServerId(); const serverId = useActiveServerId();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen); const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
@@ -235,7 +237,7 @@ export function ProjectPickerModal() {
ref={inputRef} ref={inputRef}
value={query} value={query}
onChangeText={handleChangeQuery} onChangeText={handleChangeQuery}
placeholder="Type a directory path..." placeholder={t("projectPicker.placeholder")}
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle} style={inputStyle}
autoCapitalize="none" autoCapitalize="none"
@@ -253,9 +255,9 @@ export function ProjectPickerModal() {
keyboardShouldPersistTaps="always" keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false} 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() ? ( {!isSubmitting && options.length === 0 && !query.trim() ? (
<Text style={emptyTextStyle}>Start typing a path</Text> <Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
) : null} ) : null}
{!isSubmitting && !(options.length === 0 && !query.trim()) ? ( {!isSubmitting && !(options.length === 0 && !query.trim()) ? (
<> <>
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import { SvgXml } from "react-native-svg"; import { SvgXml } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -48,6 +49,10 @@ interface CatalogRowProps {
} }
function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) { function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
const { t } = useTranslation();
const actionLabel = installing
? t("providerCatalog.actions.adding")
: t("providerCatalog.actions.add");
const handleInstall = useCallback(() => { const handleInstall = useCallback(() => {
onInstall(entry); onInstall(entry);
}, [entry, onInstall]); }, [entry, onInstall]);
@@ -84,12 +89,14 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
</Text> </Text>
<Pressable <Pressable
accessibilityRole="link" accessibilityRole="link"
accessibilityLabel={`${entry.title} install instructions`} accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
provider: entry.title,
})}
onPress={handleOpenInstallLink} onPress={handleOpenInstallLink}
style={styles.installLink} style={styles.installLink}
> >
<Text style={styles.installLinkText} numberOfLines={1}> <Text style={styles.installLinkText} numberOfLines={1}>
Install instructions {t("providerCatalog.actions.installInstructions")}
</Text> </Text>
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} /> <ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
</Pressable> </Pressable>
@@ -103,7 +110,7 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
style={styles.actionButton} style={styles.actionButton}
testID={`install-provider-${entry.id}`} testID={`install-provider-${entry.id}`}
> >
{installing ? "Adding" : "Add"} {actionLabel}
</Button> </Button>
</View> </View>
); );
@@ -114,6 +121,7 @@ export function ProviderCatalogList({
installingProviderId, installingProviderId,
onInstall, onInstall,
}: ProviderCatalogListProps) { }: ProviderCatalogListProps) {
const { t } = useTranslation();
const { entries: catalogEntries } = useAcpProviderCatalog(); const { entries: catalogEntries } = useAcpProviderCatalog();
const { entries: providerEntries } = useProvidersSnapshot(serverId); const { entries: providerEntries } = useProvidersSnapshot(serverId);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -139,10 +147,10 @@ export function ProviderCatalogList({
</View> </View>
<AdaptiveTextInput <AdaptiveTextInput
testID="provider-catalog-search" testID="provider-catalog-search"
accessibilityLabel="Search providers"
value={search} value={search}
onChangeText={setSearch} onChangeText={setSearch}
placeholder="Search providers" accessibilityLabel={t("providerCatalog.search")}
placeholder={t("providerCatalog.search")}
style={styles.searchInput} style={styles.searchInput}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@@ -151,9 +159,7 @@ export function ProviderCatalogList({
{availableEntries.length === 0 ? ( {availableEntries.length === 0 ? (
<View style={styles.stateBox}> <View style={styles.stateBox}>
<Text style={styles.stateText}> <Text style={styles.stateText}>{t("providerCatalog.noProviders")}</Text>
{search.trim().length > 0 ? "No providers found" : "All providers are installed"}
</Text>
</View> </View>
) : ( ) : (
<View style={styles.list}> <View style={styles.list}>
@@ -1,5 +1,7 @@
import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native"; import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
import type { TFunction } from "i18next";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
@@ -79,6 +81,7 @@ function CustomModelRow({
deleting: boolean; deleting: boolean;
onDelete: (modelId: string) => void; onDelete: (modelId: string) => void;
}) { }) {
const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]); const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]);
const deleteButtonStyle = useCallback( const deleteButtonStyle = useCallback(
@@ -110,7 +113,7 @@ function CustomModelRow({
hitSlop={8} hitSlop={8}
style={deleteButtonStyle} style={deleteButtonStyle}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Remove ${model.id}`} accessibilityLabel={t("settings.providers.models.removeModel", { id: model.id })}
> >
<Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} /> <Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />
</Pressable> </Pressable>
@@ -148,6 +151,7 @@ function AddCustomModelSubSheet({
onClose: () => void; onClose: () => void;
refresh: (providers?: AgentProvider[]) => Promise<void>; refresh: (providers?: AgentProvider[]) => Promise<void>;
}) { }) {
const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { config, patchConfig } = useDaemonConfig(serverId); const { config, patchConfig } = useDaemonConfig(serverId);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
@@ -182,12 +186,15 @@ function AddCustomModelSubSheet({
.then(() => refresh([provider])) .then(() => refresh([provider]))
.then(() => onClose()) .then(() => onClose())
.catch((err) => { .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)); .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 ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
@@ -199,14 +206,14 @@ function AddCustomModelSubSheet({
testID="add-custom-model-sheet" testID="add-custom-model-sheet"
> >
<View style={sheetStyles.formGroup}> <View style={sheetStyles.formGroup}>
<Text style={sheetStyles.formLabel}>Model ID</Text> <Text style={sheetStyles.formLabel}>{t("settings.providers.models.modelId")}</Text>
<AdaptiveTextInput <AdaptiveTextInput
initialValue={input} initialValue={input}
resetKey={`add-custom-${visible}`} resetKey={`add-custom-${visible}`}
value={input} value={input}
onChangeText={setInput} onChangeText={setInput}
onSubmitEditing={handleAdd} onSubmitEditing={handleAdd}
placeholder="e.g. openai/gpt-5" placeholder={t("settings.providers.models.modelIdPlaceholder")}
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@@ -217,10 +224,10 @@ function AddCustomModelSubSheet({
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null} {error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
<View style={sheetStyles.formActions}> <View style={sheetStyles.formActions}>
<Button variant="secondary" size="sm" onPress={onClose} disabled={saving}> <Button variant="secondary" size="sm" onPress={onClose} disabled={saving}>
Cancel {t("common.actions.cancel")}
</Button> </Button>
<Button variant="default" size="sm" onPress={handleAdd} disabled={!canAdd || saving}> <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> </Button>
</View> </View>
</View> </View>
@@ -239,6 +246,7 @@ function DiagnosticSubSheet({
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null); const [diagnostic, setDiagnostic] = useState<string | null>(null);
@@ -251,11 +259,13 @@ function DiagnosticSubSheet({
const result = await client.getProviderDiagnostic(provider); const result = await client.getProviderDiagnostic(provider);
setDiagnostic(result.diagnostic); setDiagnostic(result.diagnostic);
} catch (err) { } catch (err) {
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic"); setDiagnostic(
err instanceof Error ? err.message : t("settings.providers.diagnostic.failedToFetch"),
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [client, provider]); }, [client, provider, t]);
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
@@ -280,7 +290,7 @@ function DiagnosticSubSheet({
const header = useMemo<SheetHeader>( const header = useMemo<SheetHeader>(
() => ({ () => ({
title: "Diagnostic", title: t("settings.providers.diagnostic.title"),
actions: ( actions: (
<Pressable <Pressable
onPress={handleRefreshPress} onPress={handleRefreshPress}
@@ -288,7 +298,11 @@ function DiagnosticSubSheet({
hitSlop={8} hitSlop={8}
style={refreshButtonStyle} style={refreshButtonStyle}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={loading ? "Refreshing diagnostic" : "Refresh diagnostic"} accessibilityLabel={
loading
? t("settings.providers.diagnostic.refreshingAccessibility")
: t("settings.providers.diagnostic.refreshAccessibility")
}
> >
{loading ? ( {loading ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
@@ -302,6 +316,7 @@ function DiagnosticSubSheet({
handleRefreshPress, handleRefreshPress,
loading, loading,
refreshButtonStyle, refreshButtonStyle,
t,
theme.colors.foregroundMuted, theme.colors.foregroundMuted,
theme.iconSize.sm, theme.iconSize.sm,
], ],
@@ -312,7 +327,7 @@ function DiagnosticSubSheet({
body = ( body = (
<View style={sheetStyles.codeBlockLoading}> <View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> <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> </View>
); );
} else if (diagnostic) { } else if (diagnostic) {
@@ -328,7 +343,7 @@ function DiagnosticSubSheet({
} else { } else {
body = ( body = (
<View style={sheetStyles.codeBlockLoading}> <View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>No diagnostic available</Text> <Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
</View> </View>
); );
} }
@@ -366,6 +381,7 @@ interface ProviderSheetFooterInput {
fetchedAtLabel: string | null; fetchedAtLabel: string | null;
isCompact: boolean; isCompact: boolean;
modelsRefreshing: boolean; modelsRefreshing: boolean;
t: TFunction;
onOpenAddSheet: () => void; onOpenAddSheet: () => void;
onOpenDiagSheet: () => void; onOpenDiagSheet: () => void;
onRefreshModels: () => void; onRefreshModels: () => void;
@@ -375,6 +391,7 @@ function renderProviderSheetFooter({
fetchedAtLabel, fetchedAtLabel,
isCompact, isCompact,
modelsRefreshing, modelsRefreshing,
t,
onOpenAddSheet, onOpenAddSheet,
onOpenDiagSheet, onOpenDiagSheet,
onRefreshModels, onRefreshModels,
@@ -388,7 +405,7 @@ function renderProviderSheetFooter({
<View style={contentStyle}> <View style={contentStyle}>
{fetchedAtLabel || !isCompact ? ( {fetchedAtLabel || !isCompact ? (
<Text style={metaStyle} numberOfLines={1}> <Text style={metaStyle} numberOfLines={1}>
{fetchedAtLabel ? `Updated ${fetchedAtLabel}` : ""} {fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""}
</Text> </Text>
) : null} ) : null}
<View style={actionsStyle}> <View style={actionsStyle}>
@@ -399,7 +416,7 @@ function renderProviderSheetFooter({
onPress={onOpenAddSheet} onPress={onOpenAddSheet}
style={buttonStyle} style={buttonStyle}
> >
Add model {t("settings.providers.models.addModel")}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
@@ -408,7 +425,7 @@ function renderProviderSheetFooter({
onPress={onOpenDiagSheet} onPress={onOpenDiagSheet}
style={buttonStyle} style={buttonStyle}
> >
Diagnostic {t("settings.providers.diagnostic.button")}
</Button> </Button>
<Button <Button
variant="default" variant="default"
@@ -418,7 +435,9 @@ function renderProviderSheetFooter({
disabled={modelsRefreshing} disabled={modelsRefreshing}
style={buttonStyle} style={buttonStyle}
> >
{modelsRefreshing ? "Refreshing…" : "Refresh"} {modelsRefreshing
? t("settings.providers.diagnostic.refreshing")
: t("settings.providers.diagnostic.refresh")}
</Button> </Button>
</View> </View>
</View> </View>
@@ -426,6 +445,7 @@ function renderProviderSheetFooter({
} }
function ProviderModalBody(props: ProviderModalBodyProps) { function ProviderModalBody(props: ProviderModalBodyProps) {
const { t } = useTranslation();
const { const {
discoveredCount, discoveredCount,
additionalCount, additionalCount,
@@ -445,7 +465,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
return ( return (
<View style={sheetStyles.emptyState}> <View style={sheetStyles.emptyState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> <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> </View>
); );
} }
@@ -455,7 +475,9 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text> <Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
<Button variant="default" size="sm" onPress={onRefresh} disabled={modelsRefreshing}> <Button variant="default" size="sm" onPress={onRefresh} disabled={modelsRefreshing}>
{modelsRefreshing ? "Retrying…" : "Retry"} {modelsRefreshing
? t("settings.providers.models.retrying")
: t("settings.providers.models.retry")}
</Button> </Button>
</View> </View>
); );
@@ -463,14 +485,14 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
if (filteredDiscovered.length === 0 && filteredCustom.length === 0 && searchActive) { if (filteredDiscovered.length === 0 && filteredCustom.length === 0 && searchActive) {
return ( return (
<View style={sheetStyles.emptyState}> <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> </View>
); );
} }
if (discoveredCount === 0 && additionalCount === 0) { if (discoveredCount === 0 && additionalCount === 0) {
return ( return (
<View style={sheetStyles.emptyState}> <View style={sheetStyles.emptyState}>
<Text style={sheetStyles.mutedText}>No models detected</Text> <Text style={sheetStyles.mutedText}>{t("settings.providers.models.noneDetected")}</Text>
</View> </View>
); );
} }
@@ -478,7 +500,10 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
<> <>
{filteredDiscovered.length > 0 ? ( {filteredDiscovered.length > 0 ? (
<View style={sheetStyles.section}> <View style={sheetStyles.section}>
<SectionHeader title="Discovered" count={filteredDiscovered.length} /> <SectionHeader
title={t("settings.providers.models.discovered")}
count={filteredDiscovered.length}
/>
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
{filteredDiscovered.map((model) => ( {filteredDiscovered.map((model) => (
<DiscoveredModelRow key={model.id} model={model} /> <DiscoveredModelRow key={model.id} model={model} />
@@ -488,7 +513,10 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
) : null} ) : null}
{filteredCustom.length > 0 ? ( {filteredCustom.length > 0 ? (
<View style={sheetStyles.section}> <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}> <View style={settingsStyles.card}>
{filteredCustom.map((model) => ( {filteredCustom.map((model) => (
<CustomModelRow <CustomModelRow
@@ -511,6 +539,7 @@ export function ProviderDiagnosticSheet({
onClose, onClose,
serverId, serverId,
}: ProviderDiagnosticSheetProps) { }: ProviderDiagnosticSheetProps) {
const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId); const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
@@ -531,7 +560,9 @@ export function ProviderDiagnosticSheet({
); );
const providerSnapshotRefreshing = providerEntry?.status === "loading"; const providerSnapshotRefreshing = providerEntry?.status === "loading";
const providerErrorMessage = 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 modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
const stableDiscoveredRef = useRef<AgentModelDefinition[]>([]); const stableDiscoveredRef = useRef<AgentModelDefinition[]>([]);
@@ -546,7 +577,7 @@ export function ProviderDiagnosticSheet({
const [clockTick, setClockTick] = useState(0); const [clockTick, setClockTick] = useState(0);
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
const id = setInterval(() => setClockTick((t) => t + 1), 10_000); const id = setInterval(() => setClockTick((tick) => tick + 1), 10_000);
return () => clearInterval(id); return () => clearInterval(id);
}, [visible]); }, [visible]);
const fetchedAtLabel = useMemo(() => { const fetchedAtLabel = useMemo(() => {
@@ -605,11 +636,11 @@ export function ProviderDiagnosticSheet({
title: providerLabel, title: providerLabel,
search: { search: {
onChange: setQuery, onChange: setQuery,
placeholder: "Search models", placeholder: t("settings.providers.models.searchPlaceholder"),
testID: "provider-settings-search", testID: "provider-settings-search",
}, },
}), }),
[providerLabel], [providerLabel, t],
); );
return ( return (
@@ -623,6 +654,7 @@ export function ProviderDiagnosticSheet({
fetchedAtLabel, fetchedAtLabel,
isCompact, isCompact,
modelsRefreshing, modelsRefreshing,
t,
onOpenAddSheet: handleOpenAddSheet, onOpenAddSheet: handleOpenAddSheet,
onOpenDiagSheet: handleOpenDiagSheet, onOpenDiagSheet: handleOpenDiagSheet,
onRefreshModels: handleRefreshModels, onRefreshModels: handleRefreshModels,
@@ -138,6 +138,9 @@ export function shouldSubmitEmptyOnDismiss(questions: QuestionFormQuestion[]): b
); );
} }
export function resolveDismissLabel(questions: QuestionFormQuestion[]): string { export function resolveDismissLabel(
return questions.find((question) => question.dismissLabel)?.dismissLabel ?? "Dismiss"; questions: QuestionFormQuestion[],
fallbackLabel = "Dismiss",
): string {
return questions.find((question) => question.dismissLabel)?.dismissLabel ?? fallbackLabel;
} }
@@ -10,6 +10,7 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, X } from "lucide-react-native"; import { Check, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
@@ -33,9 +34,17 @@ interface QuestionFormCardProps {
const IS_WEB = isWeb; const IS_WEB = isWeb;
function getQuestionInputPlaceholder(question: QuestionFormQuestion): string { function getQuestionInputPlaceholder({
question,
answerPlaceholder,
otherPlaceholder,
}: {
question: QuestionFormQuestion;
answerPlaceholder: string;
otherPlaceholder: string;
}): string {
return ( 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) { export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const questions = useMemo( const questions = useMemo(
() => parseQuestionFormQuestions(permission.request.input), () => parseQuestionFormQuestions(permission.request.input),
@@ -367,7 +377,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
); );
const primaryDisabled = isResponding || (isLastQuestion ? !allAnswered : !activeQuestionAnswered); 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( const submitButtonStyle = useCallback(
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ ({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton, styles.actionButton,
@@ -417,7 +429,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
return null; return null;
} }
const dismissLabel = resolveDismissLabel(questions); const dismissLabel = resolveDismissLabel(questions, t("common.actions.dismiss"));
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>(); const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? ""; const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false; const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
@@ -470,7 +482,11 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
qIndex={resolvedActiveQuestionIndex} qIndex={resolvedActiveQuestionIndex}
accessibilityLabel={activeQuestion.question} accessibilityLabel={activeQuestion.question}
value={otherText} value={otherText}
placeholder={getQuestionInputPlaceholder(activeQuestion)} placeholder={getQuestionInputPlaceholder({
question: activeQuestion,
answerPlaceholder: t("message.question.answerPlaceholder"),
otherPlaceholder: t("message.question.otherPlaceholder"),
})}
isResponding={isResponding} isResponding={isResponding}
onChange={setOtherText} onChange={setOtherText}
onSubmit={handlePrimaryAction} onSubmit={handlePrimaryAction}
@@ -1,11 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { listenToDesktopEvent } from "@/desktop/electron/events"; import { listenToDesktopEvent } from "@/desktop/electron/events";
export function QuittingOverlay() { export function QuittingOverlay() {
const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [quitting, setQuitting] = useState(false); const [quitting, setQuitting] = useState(false);
@@ -37,8 +39,8 @@ export function QuittingOverlay() {
return ( return (
<View style={styles.overlay}> <View style={styles.overlay}>
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} /> <LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
<Text style={styles.title}>Quitting Paseo…</Text> <Text style={styles.title}>{t("desktop.quitting.title")}</Text>
<Text style={styles.detail}>Stopping the local daemon.</Text> <Text style={styles.detail}>{t("desktop.quitting.detail")}</Text>
</View> </View>
); );
} }
@@ -1,4 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Pressable, View } from "react-native"; import { ActivityIndicator, Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native"; import { Mic, MicOff, Square } from "lucide-react-native";
@@ -23,6 +24,7 @@ export function RealtimeVoiceOverlay({
onStop, onStop,
}: RealtimeVoiceOverlayProps) { }: RealtimeVoiceOverlayProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const { volume, isSpeaking } = useVoiceTelemetry(); const { volume, isSpeaking } = useVoiceTelemetry();
const muteButtonStyle = useMemo( const muteButtonStyle = useMemo(
() => [ () => [
@@ -53,7 +55,9 @@ export function RealtimeVoiceOverlay({
onPress={onToggleMute} onPress={onToggleMute}
disabled={isSwitching} disabled={isSwitching}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={isMuted ? "Unmute realtime voice" : "Mute realtime voice"} accessibilityLabel={
isMuted ? t("realtimeVoice.actions.unmute") : t("realtimeVoice.actions.mute")
}
style={muteButtonStyle} style={muteButtonStyle}
> >
{isMuted ? ( {isMuted ? (
@@ -67,7 +71,7 @@ export function RealtimeVoiceOverlay({
onPress={onStop} onPress={onStop}
disabled={isSwitching} disabled={isSwitching}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Stop realtime voice and interrupt turn" accessibilityLabel={t("realtimeVoice.actions.stop")}
style={stopButtonStyle} style={stopButtonStyle}
> >
{isSwitching ? ( {isSwitching ? (
+10 -7
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text, TextInput, View } from "react-native"; import { Text, TextInput, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { import {
AdaptiveModalSheet, AdaptiveModalSheet,
AdaptiveTextInput, AdaptiveTextInput,
@@ -27,13 +28,14 @@ export function AdaptiveRenameModal({
title, title,
initialValue, initialValue,
placeholder, placeholder,
submitLabel = "Rename", submitLabel,
onClose, onClose,
onSubmit, onSubmit,
validate, validate,
maxLength, maxLength,
testID, testID,
}: AdaptiveRenameModalProps) { }: AdaptiveRenameModalProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState(initialValue); const [draft, setDraft] = useState(initialValue);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false); const [isPending, setIsPending] = useState(false);
@@ -64,10 +66,10 @@ export function AdaptiveRenameModal({
const computeError = useCallback( const computeError = useCallback(
(value: string): string | null => { (value: string): string | null => {
if (!value.trim()) return "Name is required"; if (!value.trim()) return t("common.errors.nameRequired");
return validate ? validate(value) : null; return validate ? validate(value) : null;
}, },
[validate], [validate, t],
); );
const handleChange = useCallback((value: string) => { const handleChange = useCallback((value: string) => {
@@ -91,10 +93,11 @@ export function AdaptiveRenameModal({
onClose(); onClose();
} catch (err) { } catch (err) {
setIsPending(false); 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); setError(message);
} }
}, [isPending, draft, initialValue, computeError, onSubmit, onClose]); }, [isPending, draft, initialValue, computeError, onSubmit, onClose, t]);
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
if (isPending) return; if (isPending) return;
@@ -147,7 +150,7 @@ export function AdaptiveRenameModal({
disabled={isPending} disabled={isPending}
testID={cancelTestID} testID={cancelTestID}
> >
Cancel {t("common.actions.cancel")}
</Button> </Button>
<Button <Button
variant="default" variant="default"
@@ -157,7 +160,7 @@ export function AdaptiveRenameModal({
disabled={submitDisabled} disabled={submitDisabled}
testID={submitTestID} testID={submitTestID}
> >
{isPending ? "Saving..." : submitLabel} {isPending ? t("renameModal.saving") : (submitLabel ?? t("renameModal.rename"))}
</Button> </Button>
</View> </View>
</View> </View>
@@ -1,4 +1,5 @@
import { memo, useCallback, useMemo, useState, type ReactElement } from "react"; import { memo, useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { FileText, Layers, MessageSquare, Undo2 } from "lucide-react-native"; import { FileText, Layers, MessageSquare, Undo2 } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -42,7 +43,16 @@ export const RewindMenu = memo(function RewindMenu({
testID = "rewind-menu", testID = "rewind-menu",
}: RewindMenuProps) { }: RewindMenuProps) {
const { theme } = useUnistyles(); 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 [isOpen, setIsOpen] = useState(false);
const [pendingMode, setPendingMode] = useState<RewindMode | null>(null); const [pendingMode, setPendingMode] = useState<RewindMode | null>(null);
const isLocked = isPendingProp || pendingMode !== null; const isLocked = isPendingProp || pendingMode !== null;
@@ -79,10 +89,10 @@ export const RewindMenu = memo(function RewindMenu({
const tooltipContent = useMemo( const tooltipContent = useMemo(
() => ( () => (
<TooltipContent side="top" align="center" offset={8}> <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> </TooltipContent>
), ),
[], [t],
); );
if (items.length === 0) { if (items.length === 0) {
@@ -95,7 +105,7 @@ export const RewindMenu = memo(function RewindMenu({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<View style={styles.triggerSlot} collapsable={false}> <View style={styles.triggerSlot} collapsable={false}>
<DropdownMenuTrigger <DropdownMenuTrigger
accessibilityLabel="Rewind to this message" accessibilityLabel={t("rewind.tooltip")}
accessibilityRole="button" accessibilityRole="button"
disabled={isLocked} disabled={isLocked}
style={triggerStyle} style={triggerStyle}
@@ -114,7 +124,7 @@ export const RewindMenu = memo(function RewindMenu({
</Tooltip> </Tooltip>
<DropdownMenuContent align="end" minWidth={220} side="bottom" testID={`${testID}-content`}> <DropdownMenuContent align="end" minWidth={220} side="bottom" testID={`${testID}-content`}>
<View style={styles.warningHeader}> <View style={styles.warningHeader}>
<Text style={styles.warningText}>This action cannot be undone</Text> <Text style={styles.warningText}>{t("rewind.warning")}</Text>
</View> </View>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{items.map((item) => ( {items.map((item) => (
@@ -1,4 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -25,11 +26,12 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
isPending: boolean; isPending: boolean;
} { } {
const toast = useToast(); const toast = useToast();
const { t } = useTranslation();
const composerRestore = useRewindComposerRestore(); const composerRestore = useRewindComposerRestore();
const { isPending, mutateAsync } = useMutation({ const { isPending, mutateAsync } = useMutation({
mutationFn: async ({ mode }: RewindAgentInput) => { mutationFn: async ({ mode }: RewindAgentInput) => {
if (!input.client || !input.agentId || !input.messageId) { 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); await input.client.rewindAgent(input.agentId, input.messageId, mode);
if (mode !== "files") { if (mode !== "files") {
@@ -59,7 +61,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
composerRestore?.restoreTextIfComposerEmpty(variables.rewoundText); composerRestore?.restoreTextIfComposerEmpty(variables.rewoundText);
}, },
onError: (error) => { onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to rewind agent"); toast.error(error instanceof Error ? error.message : t("rewind.errors.failed"));
}, },
}); });
@@ -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"]);
});
}); });
@@ -9,6 +9,18 @@ export interface RewindMenuItem {
testID: string; 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( export function resolveRewindMenuItems(
capabilities: capabilities:
| Pick< | Pick<
@@ -17,29 +29,31 @@ export function resolveRewindMenuItems(
> >
| null | null
| undefined, | undefined,
labelsInput?: Partial<RewindMenuLabels>,
): RewindMenuItem[] { ): RewindMenuItem[] {
if (!capabilities) { if (!capabilities) {
return []; return [];
} }
const labels = { ...DEFAULT_REWIND_MENU_LABELS, ...labelsInput };
const items: RewindMenuItem[] = []; const items: RewindMenuItem[] = [];
if (capabilities.supportsRewindConversation) { if (capabilities.supportsRewindConversation) {
items.push({ items.push({
mode: "conversation", mode: "conversation",
label: "Rewind conversation", label: labels.conversation,
testID: "rewind-menu-conversation", testID: "rewind-menu-conversation",
}); });
} }
if (capabilities.supportsRewindFiles) { if (capabilities.supportsRewindFiles) {
items.push({ items.push({
mode: "files", mode: "files",
label: "Rewind files", label: labels.files,
testID: "rewind-menu-files", testID: "rewind-menu-files",
}); });
} }
if (capabilities.supportsRewindBoth) { if (capabilities.supportsRewindBoth) {
items.push({ items.push({
mode: "both", mode: "both",
label: "Rewind conversation and files", label: labels.both,
testID: "rewind-menu-both", testID: "rewind-menu-both",
}); });
} }
@@ -48,6 +62,7 @@ export function resolveRewindMenuItems(
export function useRewindCapabilities( export function useRewindCapabilities(
capabilities: Parameters<typeof resolveRewindMenuItems>[0], capabilities: Parameters<typeof resolveRewindMenuItems>[0],
labels?: Partial<RewindMenuLabels>,
): RewindMenuItem[] { ): RewindMenuItem[] {
return useMemo(() => resolveRewindMenuItems(capabilities), [capabilities]); return useMemo(() => resolveRewindMenuItems(capabilities, labels), [capabilities, labels]);
} }
@@ -2,6 +2,7 @@ import { X } from "lucide-react-native";
import { useCallback, useMemo, type ReactNode } from "react"; import { useCallback, useMemo, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
export type SidebarCalloutActionVariant = "primary" | "secondary"; export type SidebarCalloutActionVariant = "primary" | "secondary";
@@ -38,6 +39,7 @@ export function SidebarCallout({
onDismiss, onDismiss,
testID, testID,
}: SidebarCalloutProps) { }: SidebarCalloutProps) {
const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const visibleActions = (actions ?? []).slice(0, 2); const visibleActions = (actions ?? []).slice(0, 2);
const hasHeader = title != null || icon != null; const hasHeader = title != null || icon != null;
@@ -69,7 +71,7 @@ export function SidebarCallout({
hitSlop={8} hitSlop={8}
style={styles.dismissButton} style={styles.dismissButton}
testID={testID ? `${testID}-dismiss` : undefined} testID={testID ? `${testID}-dismiss` : undefined}
accessibilityLabel="Dismiss" accessibilityLabel={t("sidebarCallout.dismiss")}
accessibilityRole="button" accessibilityRole="button"
> >
{({ hovered }) => ( {({ hovered }) => (
@@ -27,6 +27,7 @@ import {
type MutableRefObject, type MutableRefObject,
type Ref, type Ref,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { router, usePathname, type Href } from "expo-router"; import { router, usePathname, type Href } from "expo-router";
import { import {
navigateToWorkspace, navigateToWorkspace,
@@ -123,7 +124,10 @@ import {
requireWorkspaceExecutionDirectory, requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory, resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution"; } from "@/utils/workspace-execution";
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning"; import {
confirmRiskyWorktreeArchive,
type WorktreeArchiveWarningLabels,
} from "@/git/worktree-archive-warning";
import { import {
archiveWorkspaceOptimistically, archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically, archiveWorkspacesOptimistically,
@@ -183,6 +187,40 @@ const syncedLoaderColorMapping = (theme: Theme) => ({
: theme.colors.palette.amber[500], : 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"]) { function getPrIconUniMapping(state: PrHint["state"]) {
switch (state) { switch (state) {
case "merged": case "merged":
@@ -307,6 +345,7 @@ function getWorkspaceArchiveStatus(
} }
export function PrBadge({ hint }: { hint: PrHint }) { export function PrBadge({ hint }: { hint: PrHint }) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const handlePressIn = useCallback((event: GestureResponderEvent) => { const handlePressIn = useCallback((event: GestureResponderEvent) => {
@@ -330,7 +369,9 @@ export function PrBadge({ hint }: { hint: PrHint }) {
return ( return (
<Pressable <Pressable
accessibilityRole="link" accessibilityRole="link"
accessibilityLabel={`Pull request #${hint.number}`} accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
number: hint.number,
})}
hitSlop={4} hitSlop={4}
onPressIn={handlePressIn} onPressIn={handlePressIn}
onPress={handlePress} onPress={handlePress}
@@ -572,6 +613,7 @@ function ProjectKebabMenu({
onRemoveProject: () => void; onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success"; removeProjectStatus: "idle" | "pending" | "success";
}) { }) {
const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const handleOpenProjectSettings = useCallback(() => { const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return; if (projectKey.trim().length === 0) return;
@@ -589,16 +631,16 @@ function ProjectKebabMenu({
?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath }) ?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath })
?.catch((error) => { ?.catch((error) => {
console.warn("[sidebar] openNew failed", 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 ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
hitSlop={8} hitSlop={8}
style={projectKebabStyle} style={projectKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"} accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Project actions" accessibilityLabel={t("sidebar.project.actions.menu")}
testID={`sidebar-project-kebab-${projectKey}`} testID={`sidebar-project-kebab-${projectKey}`}
> >
{renderKebabTriggerIcon} {renderKebabTriggerIcon}
@@ -610,7 +652,7 @@ function ProjectKebabMenu({
leading={settingsLeadingIcon} leading={settingsLeadingIcon}
onSelect={handleOpenProjectSettings} onSelect={handleOpenProjectSettings}
> >
Open project settings {t("sidebar.project.actions.openSettings")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{canOpenInNewWindow ? ( {canOpenInNewWindow ? (
@@ -619,17 +661,17 @@ function ProjectKebabMenu({
leading={openInNewWindowLeadingIcon} leading={openInNewWindowLeadingIcon}
onSelect={handleOpenInNewWindow} onSelect={handleOpenInNewWindow}
> >
Open in new window {t("sidebar.project.actions.openNewWindow")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
<DropdownMenuItem <DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`} testID={`sidebar-project-menu-remove-${projectKey}`}
leading={trash2LeadingIcon} leading={trash2LeadingIcon}
status={removeProjectStatus} status={removeProjectStatus}
pendingLabel="Removing..." pendingLabel={t("sidebar.project.actions.removing")}
onSelect={onRemoveProject} onSelect={onRemoveProject}
> >
Remove project {t("sidebar.project.actions.remove")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -669,13 +711,16 @@ function WorkspaceRowRightGroup({
onCopyPath?: () => void; onCopyPath?: () => void;
onRename?: () => void; onRename?: () => void;
}) { }) {
const { t } = useTranslation();
const showShortcut = showShortcutBadge && shortcutNumber !== null; const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut; const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat); const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
return ( return (
<> <>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null} {isCreating ? (
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
) : null}
{shouldRenderActionSlot ? ( {shouldRenderActionSlot ? (
<SidebarWorkspaceTrailingActionSlot> <SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase <SidebarWorkspaceTrailingActionBase
@@ -733,6 +778,7 @@ function WorkspaceKebabMenu({
archivePendingLabel?: string; archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null; archiveShortcutKeys?: ShortcutKey[][] | null;
}) { }) {
const { t } = useTranslation();
const archiveTrailing = useMemo( const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null), () => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys], [archiveShortcutKeys],
@@ -743,7 +789,7 @@ function WorkspaceKebabMenu({
hitSlop={8} hitSlop={8}
style={workspaceKebabStyle} style={workspaceKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"} accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Workspace actions" accessibilityLabel={t("sidebar.workspace.actions.menu")}
testID={`sidebar-workspace-kebab-${workspaceKey}`} testID={`sidebar-workspace-kebab-${workspaceKey}`}
> >
{renderKebabTriggerIcon} {renderKebabTriggerIcon}
@@ -755,7 +801,7 @@ function WorkspaceKebabMenu({
leading={copyLeadingIcon} leading={copyLeadingIcon}
onSelect={onCopyPath} onSelect={onCopyPath}
> >
Copy path {t("sidebar.workspace.actions.copyPath")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{onCopyBranchName ? ( {onCopyBranchName ? (
@@ -764,7 +810,7 @@ function WorkspaceKebabMenu({
leading={copyLeadingIcon} leading={copyLeadingIcon}
onSelect={onCopyBranchName} onSelect={onCopyBranchName}
> >
Copy branch name {t("sidebar.workspace.actions.copyBranchName")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{onRename ? ( {onRename ? (
@@ -773,7 +819,7 @@ function WorkspaceKebabMenu({
leading={renameLeadingIcon} leading={renameLeadingIcon}
onSelect={onRename} onSelect={onRename}
> >
Rename workspace {t("sidebar.workspace.actions.rename")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{onMarkAsRead ? ( {onMarkAsRead ? (
@@ -793,7 +839,7 @@ function WorkspaceKebabMenu({
pendingLabel={archivePendingLabel} pendingLabel={archivePendingLabel}
onSelect={onArchive} onSelect={onArchive}
> >
{archiveLabel ?? "Archive"} {archiveLabel ?? t("sidebar.workspace.actions.archive")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -912,6 +958,7 @@ function NewWorktreeButton({
testID: string; testID: string;
showShortcutHint?: boolean; showShortcutHint?: boolean;
}) { }) {
const { t } = useTranslation();
const newWorktreeKeys = useShortcutKeys("new-worktree"); const newWorktreeKeys = useShortcutKeys("new-worktree");
const pressableStyle = useCallback( const pressableStyle = useCallback(
@@ -940,7 +987,9 @@ function NewWorktreeButton({
onPress={handlePress} onPress={handlePress}
disabled={loading} disabled={loading}
accessibilityRole={platformIsWeb ? undefined : "button"} accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel={`Create a new workspace for ${displayName}`} accessibilityLabel={t("sidebar.workspace.actions.createWorkspaceFor", {
projectName: displayName,
})}
testID={testID} testID={testID}
> >
{({ hovered, pressed }) => {({ hovered, pressed }) =>
@@ -959,7 +1008,9 @@ function NewWorktreeButton({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}> <TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.projectActionTooltipRow}> <View style={styles.projectActionTooltipRow}>
<Text style={styles.projectActionTooltipText}>New workspace</Text> <Text style={styles.projectActionTooltipText}>
{t("sidebar.workspace.actions.newWorkspace")}
</Text>
{showShortcutHint && newWorktreeKeys ? ( {showShortcutHint && newWorktreeKeys ? (
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} /> <Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
) : null} ) : null}
@@ -1481,6 +1532,7 @@ function WorkspaceRowWithMenu({
canCopyBranchName: boolean; canCopyBranchName: boolean;
isCreating?: boolean; isCreating?: boolean;
}) { }) {
const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -1513,12 +1565,15 @@ function WorkspaceRowWithMenu({
return; return;
} }
const confirmed = await confirmRiskyWorktreeArchive({ const confirmed = await confirmRiskyWorktreeArchive(
worktreeName: workspace.name, {
isDirty: workspace.archiveHasUncommittedChanges, worktreeName: workspace.name,
aheadOfOrigin: workspace.archiveUnpushedCommitCount, isDirty: workspace.archiveHasUncommittedChanges,
diffStat: workspace.diffStat, aheadOfOrigin: workspace.archiveUnpushedCommitCount,
}); diffStat: workspace.diffStat,
},
getWorktreeArchiveWarningLabels(t),
);
if (!confirmed) { if (!confirmed) {
return; return;
@@ -1530,12 +1585,16 @@ function WorkspaceRowWithMenu({
workspaceDirectory: workspace.workspaceDirectory, workspaceDirectory: workspace.workspaceDirectory,
}); });
} catch (error) { } 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; return;
} }
if (!archiveDirectory) { if (!archiveDirectory) {
toast.error("Workspace path not available"); toast.error(t("sidebar.workspace.toasts.workspacePathUnavailable"));
return; return;
} }
@@ -1546,10 +1605,11 @@ function WorkspaceRowWithMenu({
cwd: archiveDirectory, cwd: archiveDirectory,
worktreePath: archiveDirectory, worktreePath: archiveDirectory,
}).catch((error) => { }).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); toast.error(message);
}); });
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]); }, [archiveWorktree, isArchiving, redirectAfterArchive, t, toast, workspace]);
const handleArchiveWorktree = useCallback(() => { const handleArchiveWorktree = useCallback(() => {
void archiveWorktreeAfterConfirmation(); void archiveWorktreeAfterConfirmation();
@@ -1561,10 +1621,10 @@ function WorkspaceRowWithMenu({
} }
const confirmed = await confirmDialog({ const confirmed = await confirmDialog({
title: "Hide workspace?", title: t("sidebar.workspace.confirmations.hideTitle"),
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, message: t("sidebar.workspace.confirmations.hideMessage", { workspaceName: workspace.name }),
confirmLabel: "Hide", confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"),
cancelLabel: "Cancel", cancelLabel: t("sidebar.workspace.confirmations.cancel"),
destructive: true, destructive: true,
}); });
if (!confirmed) { if (!confirmed) {
@@ -1573,7 +1633,7 @@ function WorkspaceRowWithMenu({
const client = getHostRuntimeStore().getClient(workspace.serverId); const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) { if (!client) {
toast.error("Host is not connected"); toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
return; return;
} }
@@ -1585,11 +1645,13 @@ function WorkspaceRowWithMenu({
afterHide: redirectAfterArchive, afterHide: redirectAfterArchive,
}); });
} catch (error) { } 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 { } finally {
setIsArchivingWorkspace(false); setIsArchivingWorkspace(false);
} }
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]); }, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
const handleArchiveWorkspace = useCallback(() => { const handleArchiveWorkspace = useCallback(() => {
void hideWorkspaceAfterConfirmation(); void hideWorkspaceAfterConfirmation();
@@ -1603,23 +1665,27 @@ function WorkspaceRowWithMenu({
workspaceDirectory: workspace.workspaceDirectory, workspaceDirectory: workspace.workspaceDirectory,
}); });
} catch (error) { } 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; return;
} }
void Clipboard.setStringAsync(copyTargetDirectory); void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied("Path copied"); toast.copied(t("sidebar.workspace.toasts.pathCopied"));
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]); }, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => { const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name); void Clipboard.setStringAsync(workspace.name);
toast.copied("Branch name copied"); toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
}, [toast, workspace.name]); }, [t, toast, workspace.name]);
const renameMutation = useMutation({ const renameMutation = useMutation({
mutationFn: async (branch: string) => { mutationFn: async (branch: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId); const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) { if (!client) {
throw new Error("Host is not connected"); throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
} }
const targetCwd = requireWorkspaceExecutionDirectory({ const targetCwd = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId, workspaceId: workspace.workspaceId,
@@ -1627,7 +1693,7 @@ function WorkspaceRowWithMenu({
}); });
const payload = await client.renameBranch({ cwd: targetCwd, branch }); const payload = await client.renameBranch({ cwd: targetCwd, branch });
if (!payload.success || payload.error) { 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 }; return { targetCwd };
}, },
@@ -1654,11 +1720,14 @@ function WorkspaceRowWithMenu({
[renameMutation], [renameMutation],
); );
const validateRenameSlug = useCallback((value: string): string | null => { const validateRenameSlug = useCallback(
const result = validateBranchSlug(slugify(value)); (value: string): string | null => {
if (result.valid) return null; const result = validateBranchSlug(slugify(value));
return result.error ?? "Invalid branch name"; if (result.valid) return null;
}, []); return result.error ?? t("sidebar.workspace.rename.invalidBranchName");
},
[t],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree"); const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
@@ -1700,9 +1769,17 @@ function WorkspaceRowWithMenu({
isCreating={isCreating} isCreating={isCreating}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
menuController={null} 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)} 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} onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined} onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath} onCopyPath={handleCopyPath}
@@ -1712,10 +1789,10 @@ function WorkspaceRowWithMenu({
/> />
<AdaptiveRenameModal <AdaptiveRenameModal
visible={isRenameOpen} visible={isRenameOpen}
title="Rename workspace" title={t("sidebar.workspace.rename.title")}
initialValue={workspace.name} initialValue={workspace.name}
placeholder="branch-name" placeholder="branch-name"
submitLabel="Rename" submitLabel={t("sidebar.workspace.rename.submit")}
validate={validateRenameSlug} validate={validateRenameSlug}
maxLength={MAX_SLUG_LENGTH} maxLength={MAX_SLUG_LENGTH}
onClose={handleCloseRename} onClose={handleCloseRename}
@@ -1751,6 +1828,7 @@ function NonGitProjectRowWithMenuContent({
isDragging: boolean; isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps; dragHandleProps?: DraggableListDragHandleProps;
}) { }) {
const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const contextMenu = useContextMenu(); const contextMenu = useContextMenu();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
@@ -1769,10 +1847,12 @@ function NonGitProjectRowWithMenuContent({
void (async () => { void (async () => {
const confirmed = await confirmDialog({ const confirmed = await confirmDialog({
title: "Hide workspace?", title: t("sidebar.workspace.confirmations.hideTitle"),
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, message: t("sidebar.workspace.confirmations.hideMessage", {
confirmLabel: "Hide", workspaceName: workspace.name,
cancelLabel: "Cancel", }),
confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"),
cancelLabel: t("sidebar.workspace.confirmations.cancel"),
destructive: true, destructive: true,
}); });
if (!confirmed) { if (!confirmed) {
@@ -1781,7 +1861,7 @@ function NonGitProjectRowWithMenuContent({
const client = getHostRuntimeStore().getClient(workspace.serverId); const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) { if (!client) {
toast.error("Host is not connected"); toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
return; return;
} }
@@ -1794,13 +1874,15 @@ function NonGitProjectRowWithMenuContent({
afterHide: redirectAfterArchive, afterHide: redirectAfterArchive,
}); });
} catch (error) { } 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 { } finally {
setIsArchivingWorkspace(false); setIsArchivingWorkspace(false);
} }
})(); })();
})(); })();
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]); }, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
return ( return (
<> <>
@@ -1831,11 +1913,11 @@ function NonGitProjectRowWithMenuContent({
<ContextMenuItem <ContextMenuItem
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`} testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
status={isArchivingWorkspace ? "pending" : "idle"} status={isArchivingWorkspace ? "pending" : "idle"}
pendingLabel="Hiding..." pendingLabel={t("sidebar.workspace.actions.hiding")}
destructive destructive
onSelect={handleArchiveWorkspace} onSelect={handleArchiveWorkspace}
> >
Hide from sidebar {t("sidebar.workspace.actions.hideFromSidebar")}
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</> </>
@@ -2212,6 +2294,7 @@ function ProjectBlock({
); );
const toast = useToast(); const toast = useToast();
const { t } = useTranslation();
const [isRemovingProject, setIsRemovingProject] = useState(false); const [isRemovingProject, setIsRemovingProject] = useState(false);
const handleRemoveProject = useCallback(() => { const handleRemoveProject = useCallback(() => {
@@ -2221,10 +2304,10 @@ function ProjectBlock({
void (async () => { void (async () => {
const confirmed = await confirmDialog({ const confirmed = await confirmDialog({
title: "Remove project?", title: t("sidebar.project.confirmations.removeTitle"),
message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`, message: t("sidebar.project.confirmations.removeMessage", { projectName: displayName }),
confirmLabel: "Remove", confirmLabel: t("sidebar.project.confirmations.removeConfirm"),
cancelLabel: "Cancel", cancelLabel: t("sidebar.project.confirmations.cancel"),
destructive: true, destructive: true,
}); });
if (!confirmed) { if (!confirmed) {
@@ -2233,7 +2316,7 @@ function ProjectBlock({
const client = getHostRuntimeStore().getClient(serverId); const client = getHostRuntimeStore().getClient(serverId);
if (!client) { if (!client) {
toast.error("Host is not connected"); toast.error(t("sidebar.project.toasts.hostDisconnected"));
return; return;
} }
@@ -2243,13 +2326,13 @@ function ProjectBlock({
workspaces: project.workspaces, workspaces: project.workspaces,
}).then((failures) => { }).then((failures) => {
if (failures.length > 0) { if (failures.length > 0) {
toast.error("Failed to remove some workspaces"); toast.error(t("sidebar.project.toasts.removeFailed"));
} }
setIsRemovingProject(false); setIsRemovingProject(false);
return; return;
}); });
})(); })();
}, [isRemovingProject, serverId, displayName, toast, project.workspaces]); }, [isRemovingProject, serverId, displayName, t, toast, project.workspaces]);
const flattenedRowWorkspaceId = const flattenedRowWorkspaceId =
rowModel.kind === "workspace_link" ? rowModel.workspace.workspaceId : null; rowModel.kind === "workspace_link" ? rowModel.workspace.workspaceId : null;
@@ -2476,6 +2559,7 @@ function ProjectModeList({
}: Omit<SidebarWorkspaceListProps, "groupMode" | "isRefreshing" | "onRefresh"> & { }: Omit<SidebarWorkspaceListProps, "groupMode" | "isRefreshing" | "onRefresh"> & {
pathname: string; pathname: string;
}) { }) {
const { t } = useTranslation();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set()); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>( const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(), new Map(),
@@ -2688,10 +2772,10 @@ function ProjectModeList({
<> <>
{projects.length === 0 ? ( {projects.length === 0 ? (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Text style={styles.emptyTitle}>No projects yet</Text> <Text style={styles.emptyTitle}>{t("sidebar.project.empty.title")}</Text>
<Text style={styles.emptyText}>Add a project to get started</Text> <Text style={styles.emptyText}>{t("sidebar.project.empty.description")}</Text>
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}> <Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
Add project {t("sidebar.actions.addProject")}
</Button> </Button>
</View> </View>
) : ( ) : (
@@ -1,4 +1,5 @@
import { memo, useCallback, useMemo, useState } from "react"; import { memo, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native"; import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -348,6 +349,7 @@ function StatusWorkspaceRowWithMenu({
showShortcutBadge: boolean; showShortcutBadge: boolean;
onPress: () => void; onPress: () => void;
}) { }) {
const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -419,7 +421,7 @@ function StatusWorkspaceRowWithMenu({
if (!confirmed) return; if (!confirmed) return;
const client = getHostRuntimeStore().getClient(workspace.serverId); const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) { if (!client) {
toast.error("Host is not connected"); toast.error(t("workspace.terminal.hostDisconnected"));
return; return;
} }
setIsArchivingWorkspace(true); setIsArchivingWorkspace(true);
@@ -434,7 +436,7 @@ function StatusWorkspaceRowWithMenu({
} finally { } finally {
setIsArchivingWorkspace(false); setIsArchivingWorkspace(false);
} }
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]); }, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
const handleCopyPath = useCallback(() => { const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string; let copyTargetDirectory: string;
@@ -459,7 +461,7 @@ function StatusWorkspaceRowWithMenu({
const renameMutation = useMutation({ const renameMutation = useMutation({
mutationFn: async (branch: string) => { mutationFn: async (branch: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId); 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({ const targetCwd = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId, workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory, workspaceDirectory: workspace.workspaceDirectory,
@@ -28,6 +28,7 @@ import {
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle"; import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
@@ -660,6 +661,7 @@ function DragOverlayTabChipInner({
normalizedServerId: string; normalizedServerId: string;
normalizedWorkspaceId: string; normalizedWorkspaceId: string;
}) { }) {
const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const chipStyle = useMemo( const chipStyle = useMemo(
@@ -684,7 +686,8 @@ function DragOverlayTabChipInner({
workspaceId={normalizedWorkspaceId} workspaceId={normalizedWorkspaceId}
> >
{(presentation) => { {(presentation) => {
const label = presentation.titleState === "loading" ? "Loading..." : presentation.label; const label =
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
return ( return (
<View style={chipStyle}> <View style={chipStyle}>
@@ -10,6 +10,7 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input"; import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useAppVisible } from "@/hooks/use-app-visible"; import { useAppVisible } from "@/hooks/use-app-visible";
@@ -169,6 +170,7 @@ export function TerminalPane({
onOpenFileExplorer, onOpenFileExplorer,
onOpenWorkspaceFile, onOpenWorkspaceFile,
}: TerminalPaneProps) { }: TerminalPaneProps) {
const { t } = useTranslation();
const isAppVisible = useAppVisible(); const isAppVisible = useAppVisible();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { settings } = useAppSettings(); const { settings } = useAppSettings();
@@ -750,7 +752,7 @@ export function TerminalPane({
if (!client || !isConnected) { if (!client || !isConnected) {
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
<Text style={styles.stateText}>Host is not connected</Text> <Text style={styles.stateText}>{t("workspace.terminal.hostDisconnected")}</Text>
</View> </View>
); );
} }
+4 -2
View File
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native"; import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { AlertTriangle, CheckCircle2 } from "lucide-react-native"; import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
@@ -49,6 +50,7 @@ export function useToastHost(): {
dismiss: () => void; dismiss: () => void;
} { } {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const [toast, setToast] = useState<ToastState | null>(null); const [toast, setToast] = useState<ToastState | null>(null);
const idRef = useRef(0); const idRef = useRef(0);
@@ -85,13 +87,13 @@ export function useToastHost(): {
() => ({ () => ({
show, show,
copied: (label?: string) => copied: (label?: string) =>
show(label ? `Copied ${label}` : "Copied", { show(label ? t("common.states.copiedLabel", { label }) : t("common.states.copied"), {
variant: "success", variant: "success",
icon: <CheckCircle2 size={18} color={theme.colors.foreground} />, icon: <CheckCircle2 size={18} color={theme.colors.foreground} />,
}), }),
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }), error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
}), }),
[show, theme.colors.foreground], [show, theme.colors.foreground, t],
); );
const dismiss = useCallback(() => { const dismiss = useCallback(() => {
@@ -8,6 +8,8 @@ import {
} from "react-native"; } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers"; import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
@@ -240,11 +242,12 @@ function WorktreeSetupDetailSection({
function resolveSubAgentFallbackHeader( function resolveSubAgentFallbackHeader(
subAgentType: string | null | undefined, subAgentType: string | null | undefined,
description: string | null | undefined, description: string | null | undefined,
fallbackText: string,
): string { ): string {
if (subAgentType && description) { if (subAgentType && description) {
return `${subAgentType}: ${description}`; return `${subAgentType}: ${description}`;
} }
return subAgentType ?? description ?? "Sub-agent activity"; return subAgentType ?? description ?? fallbackText;
} }
interface SubAgentDetailProps { interface SubAgentDetailProps {
@@ -365,8 +368,13 @@ function SubAgentDetailSection({
description, description,
ds, ds,
}: SubAgentDetailProps) { }: SubAgentDetailProps) {
const { t } = useTranslation();
const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]); 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; const hasActions = actions.length > 0;
return ( return (
<View style={ds.sectionFillStyle}> <View style={ds.sectionFillStyle}>
@@ -594,7 +602,7 @@ interface UnknownDetail {
output: unknown; output: unknown;
} }
function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNode[] { function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles, t: TFunction): ReactNode[] {
const plainInputText = const plainInputText =
typeof detail.input === "string" && detail.output === null ? detail.input : null; typeof detail.input === "string" && detail.output === null ? detail.input : null;
@@ -609,8 +617,8 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNod
} }
const sectionsFromTopLevel = [ const sectionsFromTopLevel = [
{ title: "Input", value: detail.input }, { title: t("toolCallDetails.input"), value: detail.input },
{ title: "Output", value: detail.output }, { title: t("toolCallDetails.output"), value: detail.output },
].filter((entry) => ].filter((entry) =>
hasMeaningfulToolCallDetail({ hasMeaningfulToolCallDetail({
type: "unknown", type: "unknown",
@@ -653,6 +661,7 @@ function buildDetailSections(
detail: ToolCallDetail | undefined, detail: ToolCallDetail | undefined,
diffLines: DiffLine[] | undefined, diffLines: DiffLine[] | undefined,
ds: DetailStyles, ds: DetailStyles,
t: TFunction,
): ReactNode[] { ): ReactNode[] {
if (!detail) return []; if (!detail) return [];
if (detail.type === "shell") { if (detail.type === "shell") {
@@ -723,15 +732,16 @@ function buildDetailSections(
return [<PlainTextSection key="plain-text" text={detail.text} />]; return [<PlainTextSection key="plain-text" text={detail.text} />];
} }
if (detail.type === "unknown") { if (detail.type === "unknown") {
return buildUnknownSections(detail, ds); return buildUnknownSections(detail, ds, t);
} }
return []; return [];
} }
function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }) { function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }) {
const { t } = useTranslation();
return ( return (
<View style={styles.section}> <View style={styles.section}>
<Text style={SECTION_TITLE_ERROR_STYLE}>Error</Text> <Text style={SECTION_TITLE_ERROR_STYLE}>{t("toolCallDetails.error")}</Text>
<ScrollView <ScrollView
horizontal horizontal
nestedScrollEnabled nestedScrollEnabled
@@ -772,11 +782,12 @@ function ToolCallDetailsContentInner({
fillAvailableHeight = false, fillAvailableHeight = false,
showLoadingSkeleton = false, showLoadingSkeleton = false,
}: ToolCallDetailsContentProps) { }: ToolCallDetailsContentProps) {
const { t } = useTranslation();
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300); const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
const ds = useDetailStyles(detail, resolvedMaxHeight, fillAvailableHeight); const ds = useDetailStyles(detail, resolvedMaxHeight, fillAvailableHeight);
const diffLines = useDiffLines(detail); const diffLines = useDiffLines(detail);
const sections: ReactNode[] = buildDetailSections(detail, diffLines, ds); const sections: ReactNode[] = buildDetailSections(detail, diffLines, ds, t);
if (errorText) { if (errorText) {
sections.push(<ErrorSection key="error" errorText={errorText} ds={ds} />); sections.push(<ErrorSection key="error" errorText={errorText} ds={ds} />);
@@ -786,7 +797,7 @@ function ToolCallDetailsContentInner({
if (showLoadingSkeleton) { if (showLoadingSkeleton) {
return <LoadingSkeleton containerStyle={ds.loadingContainerStyle} />; 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>; return <View style={ds.fullBleedContainerStyle}>{sections}</View>;
@@ -10,6 +10,7 @@ import {
type PressableStateCallbackType, type PressableStateCallbackType,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { File, Folder } from "lucide-react-native"; import { File, Folder } from "lucide-react-native";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { getAutocompleteScrollOffset } from "./autocomplete-utils"; import { getAutocompleteScrollOffset } from "./autocomplete-utils";
@@ -122,11 +123,14 @@ export function Autocomplete({
onSelect, onSelect,
isLoading = false, isLoading = false,
errorMessage, errorMessage,
loadingText = "Loading...", loadingText,
emptyText = "No results found", emptyText,
maxHeight = 220, maxHeight = 220,
}: AutocompleteProps) { }: AutocompleteProps) {
const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const resolvedLoadingText = loadingText ?? t("common.states.loading");
const resolvedEmptyText = emptyText ?? t("common.empty.noResults");
const scrollRef = useRef<ScrollView>(null); const scrollRef = useRef<ScrollView>(null);
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map()); const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
const viewportHeightRef = useRef(0); const viewportHeightRef = useRef(0);
@@ -213,7 +217,7 @@ export function Autocomplete({
return ( return (
<View style={containerStyle}> <View style={containerStyle}>
<View style={styles.emptyItem}> <View style={styles.emptyItem}>
<Text style={styles.emptyText}>{loadingText}</Text> <Text style={styles.emptyText}>{resolvedLoadingText}</Text>
</View> </View>
</View> </View>
); );
@@ -233,7 +237,7 @@ export function Autocomplete({
return ( return (
<View style={containerStyle}> <View style={containerStyle}>
<View style={styles.emptyItem}> <View style={styles.emptyItem}>
<Text style={styles.emptyText}>{emptyText}</Text> <Text style={styles.emptyText}>{resolvedEmptyText}</Text>
</View> </View>
</View> </View>
); );
+12 -7
View File
@@ -23,6 +23,7 @@ import {
type StyleProp, type StyleProp,
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { import {
@@ -1240,15 +1241,15 @@ export function Combobox({
renderOption, renderOption,
onSearchQueryChange, onSearchQueryChange,
searchable = true, searchable = true,
placeholder = "Search...", placeholder,
searchPlaceholder, searchPlaceholder,
emptyText = "No options match your search.", emptyText,
allowCustomValue = false, allowCustomValue = false,
customValuePrefix = "Use", customValuePrefix = "Use",
customValueDescription, customValueDescription,
customValueKind, customValueKind,
optionsPosition = "below-search", optionsPosition = "below-search",
title = "Select", title,
header, header,
mobileChildrenScrollEnabled = true, mobileChildrenScrollEnabled = true,
presentation, presentation,
@@ -1263,7 +1264,11 @@ export function Combobox({
anchorRef, anchorRef,
children, children,
}: ComboboxProps): ReactElement | null { }: ComboboxProps): ReactElement | null {
const { t } = useTranslation();
const { theme } = useUnistyles(); 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 isMobile = useIsCompactFormFactor();
const titleColor = theme.colors.foreground; const titleColor = theme.colors.foreground;
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition); const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
@@ -1519,7 +1524,7 @@ export function Combobox({
[], [],
); );
const effectiveSearchPlaceholder = searchPlaceholder ?? placeholder; const effectiveSearchPlaceholder = searchPlaceholder ?? resolvedPlaceholder;
const hasChildren = Boolean(children); const hasChildren = Boolean(children);
if (isMobile) { if (isMobile) {
@@ -1531,7 +1536,7 @@ export function Combobox({
handleSheetDismiss={handleSheetDismiss} handleSheetDismiss={handleSheetDismiss}
handleIndicatorStyle={handleIndicatorStyle} handleIndicatorStyle={handleIndicatorStyle}
titleColor={titleColor} titleColor={titleColor}
title={title} title={resolvedTitle}
header={header} header={header}
onClose={handleClose} onClose={handleClose}
stickyHeader={stickyHeader} stickyHeader={stickyHeader}
@@ -1547,7 +1552,7 @@ export function Combobox({
orderedVisibleOptions={orderedVisibleOptions} orderedVisibleOptions={orderedVisibleOptions}
value={value} value={value}
activeIndex={activeIndex} activeIndex={activeIndex}
emptyText={emptyText} emptyText={resolvedEmptyText}
handleSelect={handleSelect} handleSelect={handleSelect}
renderOption={renderOption} renderOption={renderOption}
> >
@@ -1580,7 +1585,7 @@ export function Combobox({
orderedVisibleOptions={orderedVisibleOptions} orderedVisibleOptions={orderedVisibleOptions}
value={value} value={value}
activeIndex={activeIndex} activeIndex={activeIndex}
emptyText={emptyText} emptyText={resolvedEmptyText}
handleSelect={handleSelect} handleSelect={handleSelect}
renderOption={renderOption} renderOption={renderOption}
hasChildren={hasChildren} hasChildren={hasChildren}
@@ -12,6 +12,7 @@ import {
type ReactNode, type ReactNode,
type Ref, type Ref,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
@@ -385,6 +386,7 @@ export function ContextMenuContent({
mobileMode?: MobileMenuMode; mobileMode?: MobileMenuMode;
testID?: string; testID?: string;
}>): ReactElement | null { }>): ReactElement | null {
const { t } = useTranslation();
const context = useContextMenuContext("ContextMenuContent"); const context = useContextMenuContext("ContextMenuContent");
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const webScrollbarStyle = useWebScrollbarStyle(); const webScrollbarStyle = useWebScrollbarStyle();
@@ -560,7 +562,7 @@ export function ContextMenuContent({
<View style={styles.overlay}> <View style={styles.overlay}>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Menu backdrop" accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop} style={styles.backdrop}
onPress={handleClose} onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined} testID={testID ? `${testID}-backdrop` : undefined}
@@ -11,6 +11,7 @@ import {
type ReactElement, type ReactElement,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Modal, Modal,
@@ -442,6 +443,7 @@ export function DropdownMenuContent({
scrollable?: boolean; scrollable?: boolean;
testID?: string; testID?: string;
}>): ReactElement | null { }>): ReactElement | null {
const { t } = useTranslation();
const { open, setOpen, triggerRef, flushPendingSelect } = const { open, setOpen, triggerRef, flushPendingSelect } =
useDropdownMenuContext("DropdownMenuContent"); useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
@@ -625,7 +627,7 @@ export function DropdownMenuContent({
<View style={styles.overlay}> <View style={styles.overlay}>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Menu backdrop" accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop} style={styles.backdrop}
onPress={handleClose} onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined} testID={testID ? `${testID}-backdrop` : undefined}
+10 -8
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, ScrollView } from "react-native"; import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -157,6 +158,7 @@ export interface WelcomeScreenProps {
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const appVersion = resolveAppVersion(); const appVersion = resolveAppVersion();
@@ -206,7 +208,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
? [ ? [
{ {
key: "direct-connection", key: "direct-connection",
label: "Direct connection", label: t("pairing.connectionMethods.direct.title"),
testID: "welcome-direct-connection", testID: "welcome-direct-connection",
primary: true, primary: true,
icon: Link2, icon: Link2,
@@ -214,7 +216,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
}, },
{ {
key: "paste-pairing-link", key: "paste-pairing-link",
label: "Paste pairing link", label: t("pairing.connectionMethods.pasteLink.title"),
testID: "welcome-paste-pairing-link", testID: "welcome-paste-pairing-link",
primary: false, primary: false,
icon: ClipboardPaste, icon: ClipboardPaste,
@@ -224,7 +226,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
: [ : [
{ {
key: "scan-qr", key: "scan-qr",
label: "Scan QR code", label: t("pairing.connectionMethods.scanQr.title"),
testID: "welcome-scan-qr", testID: "welcome-scan-qr",
primary: true, primary: true,
icon: QrCode, icon: QrCode,
@@ -232,7 +234,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
}, },
{ {
key: "direct-connection", key: "direct-connection",
label: "Direct connection", label: t("pairing.connectionMethods.direct.title"),
testID: "welcome-direct-connection", testID: "welcome-direct-connection",
primary: false, primary: false,
icon: Link2, icon: Link2,
@@ -240,7 +242,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
}, },
{ {
key: "paste-pairing-link", key: "paste-pairing-link",
label: "Paste pairing link", label: t("pairing.connectionMethods.pasteLink.title"),
testID: "welcome-paste-pairing-link", testID: "welcome-paste-pairing-link",
primary: false, primary: false,
icon: ClipboardPaste, icon: ClipboardPaste,
@@ -264,8 +266,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<View style={styles.content}> <View style={styles.content}>
<PaseoLogo size={96} /> <PaseoLogo size={96} />
<View style={styles.copyBlock}> <View style={styles.copyBlock}>
<Text style={styles.title}>Welcome to Paseo</Text> <Text style={styles.title}>{t("onboarding.title")}</Text>
<Text style={styles.subtitle}>Connect your computer to get started</Text> <Text style={styles.subtitle}>{t("onboarding.subtitle")}</Text>
{isNative ? ( {isNative ? (
<Pressable style={styles.setupLink} onPress={handleOpenPaseoSite}> <Pressable style={styles.setupLink} onPress={handleOpenPaseoSite}>
<Text style={styles.setupLinkText}>paseo.sh</Text> <Text style={styles.setupLinkText}>paseo.sh</Text>
@@ -288,7 +290,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
style={styles.settingsButton} style={styles.settingsButton}
testID="welcome-open-settings" testID="welcome-open-settings"
> >
Settings {t("onboarding.actions.settings")}
</Button> </Button>
</View> </View>
<Text style={styles.versionLabel}>{appVersionText}</Text> <Text style={styles.versionLabel}>{appVersionText}</Text>
@@ -9,6 +9,7 @@ import {
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { Dimensions, Text, View } from "react-native"; import { Dimensions, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { FadeIn, FadeOut } from "react-native-reanimated"; import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native"; import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native";
@@ -212,6 +213,7 @@ function WorkspaceHoverCardContent({
triggerRef: React.RefObject<View | null>; triggerRef: React.RefObject<View | null>;
contentRef: React.RefObject<View | null>; contentRef: React.RefObject<View | null>;
}): ReactElement | null { }): ReactElement | null {
const { t } = useTranslation();
const bottomSheetInternal = useBottomSheetModalInternal(true); const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null); const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
@@ -274,7 +276,7 @@ function WorkspaceHoverCardContent({
collapsable={false} collapsable={false}
onLayout={handleLayout} onLayout={handleLayout}
accessibilityRole="menu" accessibilityRole="menu"
accessibilityLabel="Workspace scripts" accessibilityLabel={t("workspace.hoverCard.scriptsAccessibility")}
testID="workspace-hover-card" testID="workspace-hover-card"
style={styles.card} style={styles.card}
frameStyle={frameStyle} frameStyle={frameStyle}
@@ -373,6 +375,7 @@ function ChecksSummaryContent({
checks: NonNullable<PrHint["checks"]>; checks: NonNullable<PrHint["checks"]>;
hovered: boolean; hovered: boolean;
}) { }) {
const { t } = useTranslation();
const { passed, failed, pending } = getChecksSummaryCounts(checks); const { passed, failed, pending } = getChecksSummaryCounts(checks);
const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel; const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
@@ -385,7 +388,7 @@ function ChecksSummaryContent({
) : ( ) : (
<ThemedGitHubIcon size={12} uniProps={iconUniProps} /> <ThemedGitHubIcon size={12} uniProps={iconUniProps} />
)} )}
<Text style={labelStyle}>Checks</Text> <Text style={labelStyle}>{t("workspace.git.pr.sections.checks")}</Text>
<View style={styles.checksSummaryCounts}> <View style={styles.checksSummaryCounts}>
<ChecksSummaryPill count={passed} kind="passed" /> <ChecksSummaryPill count={passed} kind="passed" />
<ChecksSummaryPill count={failed} kind="failed" /> <ChecksSummaryPill count={failed} kind="failed" />
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Image, Text, View } from "react-native"; import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/composer"; import { Composer } from "@/composer";
@@ -96,8 +97,13 @@ async function callWorkspaceCreation({
return connectedClient.openProject(input.cwd); return connectedClient.openProject(input.cwd);
} }
function failureMessageForCreationMethod(method: "create_worktree" | "open_project") { function failureMessageForCreationMethod(
return method === "create_worktree" ? "Failed to create worktree" : "Failed to open project"; method: "create_worktree" | "open_project",
t: ReturnType<typeof useTranslation>["t"],
) {
return method === "create_worktree"
? t("workspaceSetup.errors.failedCreateWorktree")
: t("workspaceSetup.errors.failedOpenProject");
} }
function buildCreateAgentOptions({ function buildCreateAgentOptions({
@@ -140,6 +146,7 @@ function buildCreateAgentOptions({
} }
export function WorkspaceSetupDialog() { export function WorkspaceSetupDialog() {
const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup); const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup); const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
@@ -170,7 +177,7 @@ export function WorkspaceSetupDialog() {
}); });
const composerState = chatDraft.composerState; const composerState = chatDraft.composerState;
if (!composerState && pendingWorkspaceSetup) { if (!composerState && pendingWorkspaceSetup) {
throw new Error("Workspace setup composer state is required"); throw new Error(t("workspaceSetup.errors.composerStateRequired"));
} }
const { icon: projectIcon } = useProjectIconQuery({ const { icon: projectIcon } = useProjectIconQuery({
@@ -218,15 +225,15 @@ export function WorkspaceSetupDialog() {
const withConnectedClient = useCallback(() => { const withConnectedClient = useCallback(() => {
if (!client || !isConnected) { if (!client || !isConnected) {
throw new Error("Host is not connected"); throw new Error(t("workspaceSetup.errors.hostDisconnected"));
} }
return client; return client;
}, [client, isConnected]); }, [client, isConnected, t]);
const ensureWorkspace = useCallback( const ensureWorkspace = useCallback(
async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => { async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => {
if (!pendingWorkspaceSetup) { if (!pendingWorkspaceSetup) {
throw new Error("No workspace setup is pending"); throw new Error(t("workspaceSetup.errors.pendingRequired"));
} }
if (createdWorkspace) { if (createdWorkspace) {
@@ -242,7 +249,7 @@ export function WorkspaceSetupDialog() {
if (payload.error || !payload.workspace) { if (payload.error || !payload.workspace) {
throw new Error( throw new Error(
payload.error ?? failureMessageForCreationMethod(pendingWorkspaceSetup.creationMethod), payload.error ?? failureMessageForCreationMethod(pendingWorkspaceSetup.creationMethod, t),
); );
} }
@@ -259,6 +266,7 @@ export function WorkspaceSetupDialog() {
mergeWorkspaces, mergeWorkspaces,
pendingWorkspaceSetup, pendingWorkspaceSetup,
setHasHydratedWorkspaces, setHasHydratedWorkspaces,
t,
withConnectedClient, withConnectedClient,
], ],
); );
@@ -284,10 +292,10 @@ export function WorkspaceSetupDialog() {
const ensuredWorkspace = await ensureWorkspace({ cwd, attachments }); const ensuredWorkspace = await ensureWorkspace({ cwd, attachments });
const connectedClient = withConnectedClient(); const connectedClient = withConnectedClient();
if (!composerState) { if (!composerState) {
throw new Error("Workspace setup composer state is required"); throw new Error(t("workspaceSetup.errors.composerStateRequired"));
} }
if (!composerState.selectedProvider) { if (!composerState.selectedProvider) {
throw new Error("Select a model"); throw new Error(t("workspaceSetup.errors.selectModel"));
} }
const wirePayload = splitComposerAttachmentsForSubmit(attachments); const wirePayload = splitComposerAttachmentsForSubmit(attachments);
@@ -334,6 +342,7 @@ export function WorkspaceSetupDialog() {
serverId, serverId,
setAgents, setAgents,
ensureWorkspace, ensureWorkspace,
t,
toast, toast,
withConnectedClient, withConnectedClient,
], ],
@@ -392,8 +401,8 @@ export function WorkspaceSetupDialog() {
); );
const sheetHeader = useMemo<SheetHeader>( const sheetHeader = useMemo<SheetHeader>(
() => ({ title: "Create workspace", subtitle: subtitleContent }), () => ({ title: t("workspaceSetup.title"), subtitle: subtitleContent }),
[subtitleContent], [subtitleContent, t],
); );
if (!pendingWorkspaceSetup || !sourceDirectory) { if (!pendingWorkspaceSetup || !sourceDirectory) {
@@ -1,4 +1,5 @@
import type { PaseoConfigRaw } from "@getpaseo/protocol/messages"; import type { PaseoConfigRaw } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { buildProjectSettingsRoute } from "@/utils/host-routes"; import { buildProjectSettingsRoute } from "@/utils/host-routes";
export interface WorktreeSetupWorkspaceInput { export interface WorktreeSetupWorkspaceInput {
@@ -64,10 +65,9 @@ export function buildWorktreeSetupCalloutPolicy(
id: calloutKey, id: calloutKey,
dismissalKey: calloutKey, dismissalKey: calloutKey,
priority: 100, priority: 100,
title: "Set up worktree scripts", title: i18n.t("sidebar.worktreeSetup.title"),
description: description: i18n.t("sidebar.worktreeSetup.description"),
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.", actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"),
actionLabel: "Open project settings",
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey), projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
testID: `worktree-setup-callout-${project.projectKey}`, testID: `worktree-setup-callout-${project.projectKey}`,
}; };
+6 -1
View File
@@ -17,6 +17,7 @@ import {
type UserMessageItem, type UserMessageItem,
} from "@/types/stream"; } from "@/types/stream";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker"; import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
import { i18n } from "@/i18n/i18next";
export interface QueuedComposerMessage { export interface QueuedComposerMessage {
id: string; id: string;
@@ -244,6 +245,7 @@ export interface SendQueuedComposerMessageNowInput {
messageId: string; messageId: string;
queue: QueueWriter; queue: QueueWriter;
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>; submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
failedToSendMessage?: string;
} }
export type SendQueuedComposerMessageNowResult = export type SendQueuedComposerMessageNowResult =
@@ -275,7 +277,10 @@ export async function sendQueuedComposerMessageNow(
}); });
return { return {
status: "failed", 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")),
}; };
} }
} }
@@ -8,6 +8,7 @@ import {
type ReactNode, type ReactNode,
type RefObject, type RefObject,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { import {
View, View,
Text, Text,
@@ -56,7 +57,7 @@ import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manife
import { import {
getFeatureHighlightColor, getFeatureHighlightColor,
getFeatureTooltip, getFeatureTooltip,
getAgentControlHint, getAgentControlHintKey,
formatThinkingOptionLabel, formatThinkingOptionLabel,
resolveAgentModelSelection, resolveAgentModelSelection,
} from "@/composer/agent-controls/utils"; } from "@/composer/agent-controls/utils";
@@ -415,6 +416,7 @@ function ControlledAgentControls({
isCompactLayout, isCompactLayout,
}: ControlledAgentControlsProps) { }: ControlledAgentControlsProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const isCompactFormFactor = useIsCompactFormFactor(); const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor; const isCompact = isCompactLayout ?? isCompactFormFactor;
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null); const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
@@ -432,7 +434,11 @@ function ControlledAgentControls({
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0, onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0,
); );
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider"); const displayProvider = findOptionLabel(
providerOptions,
selectedProviderId,
t("agentControls.provider.fallback"),
);
const formattedThinkingOptions = useMemo( const formattedThinkingOptions = useMemo(
() => toThinkingControlOptions(thinkingOptions), () => toThinkingControlOptions(thinkingOptions),
[thinkingOptions], [thinkingOptions],
@@ -440,7 +446,7 @@ function ControlledAgentControls({
const displayThinking = findOptionLabel( const displayThinking = findOptionLabel(
formattedThinkingOptions, formattedThinkingOptions,
selectedThinkingOptionId, selectedThinkingOptionId,
formattedThinkingOptions[0]?.label ?? "Unknown", formattedThinkingOptions[0]?.label ?? t("agentControls.thinking.unknown"),
); );
const ProviderIcon = resolveProviderIcon(provider); const ProviderIcon = resolveProviderIcon(provider);
@@ -709,6 +715,7 @@ const DESKTOP_SEARCH_THRESHOLD = 6;
function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const { const {
provider, provider,
providerOptions, providerOptions,
@@ -764,7 +771,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
onPress={handleProviderPress} onPress={handleProviderPress}
style={providerPressableStyle} style={providerPressableStyle}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Select agent provider" accessibilityLabel={t("agentControls.provider.select")}
testID="agent-provider-selector" testID="agent-provider-selector"
> >
<Text style={styles.modeBadgeText}>{displayProvider}</Text> <Text style={styles.modeBadgeText}>{displayProvider}</Text>
@@ -805,7 +812,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
</View> </View>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getAgentControlHint("model")}</Text> <Text style={styles.tooltipText}>{t(getAgentControlHintKey("model"))}</Text>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
@@ -821,7 +828,9 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
onPress={handleThinkingPress} onPress={handleThinkingPress}
style={thinkingPressableStyle} style={thinkingPressableStyle}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Select thinking option (${displayThinking})`} accessibilityLabel={t("agentControls.thinking.selectWithValue", {
value: displayThinking,
})}
testID="agent-thinking-selector" testID="agent-thinking-selector"
> >
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -830,7 +839,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
</Pressable> </Pressable>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getAgentControlHint("thinking")}</Text> <Text style={styles.tooltipText}>{t(getAgentControlHintKey("thinking"))}</Text>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Combobox <Combobox
@@ -901,6 +910,7 @@ interface SheetAgentControlsContentProps {
function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const { const {
provider, provider,
selectedModelId, selectedModelId,
@@ -936,6 +946,10 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
const hasThinking = comboboxThinkingOptions.length > 0; const hasThinking = comboboxThinkingOptions.length > 0;
const hasFeatures = Boolean(features && features.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 handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]);
const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]); const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]);
@@ -1012,7 +1026,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
disabled={disabled || !canSelectThinking} disabled={disabled || !canSelectThinking}
style={thinkingButtonStyle} style={thinkingButtonStyle}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Select thinking option" accessibilityLabel={t("agentControls.thinking.select")}
testID="agent-controls-thinking" testID="agent-controls-thinking"
> >
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -1025,7 +1039,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
disabled={disabled} disabled={disabled}
style={featuresButtonStyle} style={featuresButtonStyle}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Open agent features" accessibilityLabel={t("agentControls.features.open")}
testID="agent-controls-features" testID="agent-controls-features"
> >
<Settings2 size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <Settings2 size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -1038,7 +1052,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
value={selectedThinkingOptionId ?? ""} value={selectedThinkingOptionId ?? ""}
onSelect={handleSelectThinkingAndClose} onSelect={handleSelectThinkingAndClose}
searchable={false} searchable={false}
title="Thinking" title={t("agentControls.thinking.title")}
open={activeSheet === "thinking"} open={activeSheet === "thinking"}
onOpenChange={handleThinkingSheetOpenChange} onOpenChange={handleThinkingSheetOpenChange}
anchorRef={thinkingAnchorRef} anchorRef={thinkingAnchorRef}
@@ -1047,7 +1061,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
) : null} ) : null}
<AdaptiveModalSheet <AdaptiveModalSheet
header={FEATURES_SHEET_HEADER} header={featuresSheetHeader}
visible={activeSheet === "features"} visible={activeSheet === "features"}
onClose={handleCloseSheet} onClose={handleCloseSheet}
testID="agent-features-sheet" testID="agent-features-sheet"
@@ -1206,6 +1220,7 @@ function SheetFeatureItem({
onSetFeature?: (featureId: string, value: unknown) => void; onSetFeature?: (featureId: string, value: unknown) => void;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const featureSelector: AgentControlSelector = `feature-${feature.id}`; const featureSelector: AgentControlSelector = `feature-${feature.id}`;
const handleFeatureOpenChange = useMemo( const handleFeatureOpenChange = useMemo(
@@ -1257,7 +1272,9 @@ function SheetFeatureItem({
)} )}
/> />
<Text style={styles.sheetSelectText}>{feature.label}</Text> <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> </Pressable>
</View> </View>
); );
@@ -1344,8 +1361,6 @@ function ThinkingComboboxOption({
); );
} }
const FEATURES_SHEET_HEADER: SheetHeader = { title: "Features" };
export const AgentControls = memo(function AgentControls({ export const AgentControls = memo(function AgentControls({
agentId, agentId,
serverId, serverId,
@@ -7,6 +7,7 @@ import {
type ComponentType, type ComponentType,
type ReactElement, type ReactElement,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
@@ -108,6 +109,7 @@ function AgentModeControlView({
disabled = false, disabled = false,
}: AgentModeControlViewProps) { }: AgentModeControlViewProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const anchorRef = useRef<View>(null); const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -182,14 +184,14 @@ function AgentModeControlView({
const sheetHeader = useMemo<SheetHeader>( const sheetHeader = useMemo<SheetHeader>(
() => ({ () => ({
title: "Mode", title: t("agentControls.mode.title"),
search: { search: {
onChange: setSearchQuery, onChange: setSearchQuery,
placeholder: "Search modes...", placeholder: t("agentControls.mode.searchPlaceholder"),
testID: "mode-search-input", testID: "mode-search-input",
}, },
}), }),
[], [t],
); );
if (!selectedMode) return null; if (!selectedMode) return null;
@@ -203,7 +205,9 @@ function AgentModeControlView({
onPress={handlePress} onPress={handlePress}
style={pressableStyle} style={pressableStyle}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Select agent mode (${selectedModeLabel})`} accessibilityLabel={t("agentControls.mode.selectWithValue", {
value: selectedModeLabel,
})}
testID="mode-control" testID="mode-control"
> >
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null} {Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
@@ -3,17 +3,17 @@ import {
formatAgentModeLabel, formatAgentModeLabel,
getFeatureHighlightColor, getFeatureHighlightColor,
getFeatureTooltip, getFeatureTooltip,
getAgentControlHint, getAgentControlHintKey,
formatThinkingOptionLabel, formatThinkingOptionLabel,
normalizeModelId, normalizeModelId,
resolveAgentModelSelection, resolveAgentModelSelection,
} from "./utils"; } from "./utils";
describe("getAgentControlHint", () => { describe("getAgentControlHintKey", () => {
it("explains what each editable agent control does", () => { it("returns translation keys for each editable agent control hint", () => {
expect(getAgentControlHint("thinking")).toBe("Thinking mode"); expect(getAgentControlHintKey("thinking")).toBe("agentControls.hints.thinking");
expect(getAgentControlHint("model")).toBe("Change model"); expect(getAgentControlHintKey("model")).toBe("agentControls.hints.model");
expect(getAgentControlHint("mode")).toBe("Change permission mode"); expect(getAgentControlHintKey("mode")).toBe("agentControls.hints.mode");
}); });
}); });
@@ -1,16 +1,21 @@
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types"; import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { i18n } from "@/i18n/i18next";
export type ExplainedAgentControl = "mode" | "model" | "thinking"; export type ExplainedAgentControl = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow"; 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) { switch (selector) {
case "thinking": case "thinking":
return "Thinking mode"; return "agentControls.hints.thinking";
case "model": case "model":
return "Change model"; return "agentControls.hints.model";
case "mode": case "mode":
return "Change permission mode"; return "agentControls.hints.mode";
default: default:
throw new Error("unreachable"); throw new Error("unreachable");
} }
@@ -79,7 +84,7 @@ export function formatThinkingOptionLabel(option: ControlLabelInput): string {
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase(); const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
if (compactId === "xhigh" || compactLabel === "xhigh") { if (compactId === "xhigh" || compactLabel === "xhigh") {
return "Extra high"; return i18n.t("agentControls.thinking.extraHigh");
} }
return formatControlLabel(option, true); return formatControlLabel(option, true);
@@ -143,17 +148,19 @@ function resolveModelDisplay(
selectedModel: AgentModelDefinition | null, selectedModel: AgentModelDefinition | null,
preferredModelId: string | null, preferredModelId: string | null,
fallbackModel: AgentModelDefinition | null, fallbackModel: AgentModelDefinition | null,
unknownModelLabel: string,
): { activeModelId: string | null; displayModel: string } { ): { activeModelId: string | null; displayModel: string } {
return { return {
activeModelId: selectedModel?.id ?? preferredModelId ?? null, activeModelId: selectedModel?.id ?? preferredModelId ?? null,
displayModel: displayModel:
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model", selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? unknownModelLabel,
}; };
} }
function resolveThinkingDisplay( function resolveThinkingDisplay(
effectiveThinking: ThinkingOption | null, effectiveThinking: ThinkingOption | null,
selectedThinkingId: string | null, selectedThinkingId: string | null,
unknownThinkingLabel: string,
): string { ): string {
if (effectiveThinking) { if (effectiveThinking) {
return formatThinkingOptionLabel(effectiveThinking); return formatThinkingOptionLabel(effectiveThinking);
@@ -163,7 +170,7 @@ function resolveThinkingDisplay(
return formatThinkingOptionLabel({ id: selectedThinkingId }); return formatThinkingOptionLabel({ id: selectedThinkingId });
} }
return "Unknown"; return unknownThinkingLabel;
} }
export function resolveAgentModelSelection(input: { export function resolveAgentModelSelection(input: {
@@ -189,13 +196,18 @@ export function resolveAgentModelSelection(input: {
selectedModel, selectedModel,
preferredModelId, preferredModelId,
fallbackModel, fallbackModel,
i18n.t("agentControls.model.unknown"),
); );
const thinkingOptions = selectedModel?.thinkingOptions ?? null; const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel); const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel);
const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId); const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId);
const selectedThinkingId = effectiveThinking?.id ?? null; const selectedThinkingId = effectiveThinking?.id ?? null;
const displayThinking = resolveThinkingDisplay(effectiveThinking, selectedThinkingId); const displayThinking = resolveThinkingDisplay(
effectiveThinking,
selectedThinkingId,
i18n.t("agentControls.thinking.unknown"),
);
return { return {
selectedModel, selectedModel,
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MessageSquareCode, MousePointer2 } from "lucide-react-native"; import { MessageSquareCode, MousePointer2 } from "lucide-react-native";
import type { import type {
@@ -231,14 +232,15 @@ function WorkspaceAttachmentPill({
onOpen, onOpen,
onRemove, onRemove,
}: WorkspaceAttachmentPillProps) { }: WorkspaceAttachmentPillProps) {
const { t } = useTranslation();
let label: string; let label: string;
if (attachment.kind === "browser_element") { if (attachment.kind === "browser_element") {
label = `Element · ${attachment.attachment.tag}`; label = t("composer.attachments.browserElement", { tag: attachment.attachment.tag });
} else { } else {
label = label =
attachment.commentCount === 1 attachment.commentCount === 1
? "Review · 1 comment" ? t("message.attachments.reviewOne")
: `Review · ${attachment.commentCount} comments`; : t("message.attachments.reviewMany", { count: attachment.commentCount });
} }
const handleOpen = useCallback(() => { const handleOpen = useCallback(() => {
onOpen(attachment); onOpen(attachment);
@@ -253,13 +255,13 @@ function WorkspaceAttachmentPill({
onRemove={handleRemove} onRemove={handleRemove}
openAccessibilityLabel={ openAccessibilityLabel={
attachment.kind === "browser_element" attachment.kind === "browser_element"
? "Open browser element attachment" ? t("composer.attachments.openBrowserElement")
: "Open review attachment" : t("composer.attachments.openReview")
} }
removeAccessibilityLabel={ removeAccessibilityLabel={
attachment.kind === "browser_element" attachment.kind === "browser_element"
? "Remove browser element attachment" ? t("composer.attachments.removeBrowserElement")
: "Remove review attachment" : t("composer.attachments.removeReview")
} }
disabled={disabled} disabled={disabled}
> >
+10 -5
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useReducer } from "react"; import { useCallback, useMemo, useReducer } from "react";
import { useTranslation } from "react-i18next";
import type { ComposerAttachment } from "@/attachments/types"; import type { ComposerAttachment } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -105,6 +106,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onCreateSuccess, onCreateSuccess,
onCreateError, onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) { }: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
const { t } = useTranslation();
const [machine, dispatch] = useReducer( const [machine, dispatch] = useReducer(
reducer, reducer,
initialAttempt, initialAttempt,
@@ -163,7 +165,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => { async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
const pendingServerId = getPendingServerId(); const pendingServerId = getPendingServerId();
if (!pendingServerId) { 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 }); dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error; throw error;
} }
@@ -204,7 +206,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
await onCreateSuccess({ result: createResult.result, attempt }); await onCreateSuccess({ result: createResult.result, attempt });
} catch (error) { } 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 }); dispatch({ type: "CREATE_FAILED", message: resolved.message });
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" }); markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
clearPendingCreateAttempt({ draftId }); clearPendingCreateAttempt({ draftId });
@@ -222,6 +225,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onBeforeSubmit, onBeforeSubmit,
onCreateError, onCreateError,
onCreateSuccess, onCreateSuccess,
t,
updatePendingAgentId, updatePendingAgentId,
], ],
); );
@@ -229,7 +233,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const handleCreateFromInput = useCallback( const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => { async ({ text, attachments, cwd }: SubmitContext) => {
if (isSubmitting) { if (isSubmitting) {
throw new Error("Already loading"); throw new Error(t("composer.errors.alreadyLoading"));
} }
dispatch({ type: "DRAFT_SET_ERROR", message: "" }); dispatch({ type: "DRAFT_SET_ERROR", message: "" });
@@ -238,7 +242,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const trimmedPrompt = text.trim(); const trimmedPrompt = text.trim();
if (!trimmedPrompt && !allowEmptyText) { 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 }); dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error; throw error;
} }
@@ -256,7 +260,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const pendingServerId = getPendingServerId(); const pendingServerId = getPendingServerId();
if (!pendingServerId) { 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 }); dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error; throw error;
} }
@@ -294,6 +298,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onCreateStart, onCreateStart,
runCreateAttempt, runCreateAttempt,
setPendingCreateAttempt, setPendingCreateAttempt,
t,
validateBeforeSubmit, validateBeforeSubmit,
], ],
); );
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Import as ImportIcon } from "lucide-react-native"; import { Import as ImportIcon } from "lucide-react-native";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
@@ -13,6 +14,7 @@ interface ComposerImportPillProps {
} }
export function ComposerImportPill({ onPress, disabled = false }: ComposerImportPillProps) { export function ComposerImportPill({ onPress, disabled = false }: ComposerImportPillProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []); const handleHoverOut = useCallback(() => setIsHovered(false), []);
@@ -22,7 +24,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
<Pressable <Pressable
testID="composer-import-agent-pill" testID="composer-import-agent-pill"
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Import session" accessibilityLabel={t("importSession.title")}
onPress={onPress} onPress={onPress}
disabled={disabled} disabled={disabled}
onHoverIn={handleHoverIn} onHoverIn={handleHoverIn}
@@ -31,7 +33,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
> >
<ThemedImportIcon size={14} uniProps={iconColorMapping} /> <ThemedImportIcon size={14} uniProps={iconColorMapping} />
<Text style={styles.label} numberOfLines={1}> <Text style={styles.label} numberOfLines={1}>
Import session {t("importSession.title")}
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, ScrollView, Text, View } from "react-native"; import { Keyboard, ScrollView, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import ReanimatedAnimated from "react-native-reanimated"; import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -130,6 +131,8 @@ async function submitDraftCreateRequest(input: {
effectiveThinkingOptionId: string | null; effectiveThinkingOptionId: string | null;
featureValues: Record<string, unknown> | undefined; featureValues: Record<string, unknown> | undefined;
}; };
hostDisconnectedMessage: string;
selectModelMessage: string;
}): Promise<{ agentId: string | null; result: AgentSnapshotPayload }> { }): Promise<{ agentId: string | null; result: AgentSnapshotPayload }> {
const { const {
attempt, attempt,
@@ -146,12 +149,12 @@ async function submitDraftCreateRequest(input: {
invariant(workspaceDirectory, "Workspace directory is required"); invariant(workspaceDirectory, "Workspace directory is required");
invariant(workspaceExecutionAuthority, "Workspace authority is required"); invariant(workspaceExecutionAuthority, "Workspace authority is required");
if (!client) { if (!client) {
throw new Error("Host is not connected"); throw new Error(input.hostDisconnectedMessage);
} }
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider; const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
if (!provider) { if (!provider) {
throw new Error("Select a model"); throw new Error(input.selectModelMessage);
} }
const modeIdOverride = resolveDraftModeIdOverride({ const modeIdOverride = resolveDraftModeIdOverride({
autoSubmitConfig, autoSubmitConfig,
@@ -199,6 +202,7 @@ function buildDraftAgentSnapshot(input: {
selectedProvider: string | null; selectedProvider: string | null;
agentControls: { features?: Agent["features"] }; agentControls: { features?: Agent["features"] };
}; };
selectModelMessage: string;
}): Agent { }): Agent {
const { attempt, serverId, tabId, workspaceDirectory, autoSubmitConfig, composerState } = input; const { attempt, serverId, tabId, workspaceDirectory, autoSubmitConfig, composerState } = input;
invariant(workspaceDirectory, "Workspace directory is required"); invariant(workspaceDirectory, "Workspace directory is required");
@@ -213,7 +217,7 @@ function buildDraftAgentSnapshot(input: {
}); });
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider; const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
if (!provider) { if (!provider) {
throw new Error("Select a model"); throw new Error(input.selectModelMessage);
} }
return { return {
serverId, serverId,
@@ -309,6 +313,7 @@ export function WorkspaceDraftAgentTab({
onOpenWorkspaceFile, onOpenWorkspaceFile,
onOpenImportSheet, onOpenImportSheet,
}: WorkspaceDraftAgentTabProps) { }: WorkspaceDraftAgentTabProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
@@ -456,6 +461,7 @@ export function WorkspaceDraftAgentTab({
workspaceDirectory: draftWorkingDirectory, workspaceDirectory: draftWorkingDirectory,
autoSubmitConfig, autoSubmitConfig,
composerState, composerState,
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}), }),
createRequest: async ({ attempt, text, images, attachments }) => createRequest: async ({ attempt, text, images, attachments }) =>
submitDraftCreateRequest({ submitDraftCreateRequest({
@@ -468,6 +474,8 @@ export function WorkspaceDraftAgentTab({
workspaceExecutionAuthority, workspaceExecutionAuthority,
autoSubmitConfig, autoSubmitConfig,
composerState, composerState,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}), }),
onCreateSuccess: ({ result }) => { onCreateSuccess: ({ result }) => {
clearDraftInput("sent"); clearDraftInput("sent");
+104 -35
View File
@@ -6,6 +6,7 @@ import {
Image, Image,
type PressableStateCallbackType, type PressableStateCallbackType,
} from "react-native"; } from "react-native";
import type { TFunction } from "i18next";
import { import {
useState, useState,
useEffect, useEffect,
@@ -16,6 +17,7 @@ import {
type ReactElement, type ReactElement,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
@@ -144,8 +146,10 @@ function resolveCompactLayout(override: boolean | undefined, formFactor: boolean
return override ?? formFactor; return override ?? formFactor;
} }
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string { function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean, t: TFunction): string {
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER; return isDesktopWebBreakpoint
? t("composer.placeholders.desktop")
: t("composer.placeholders.mobile");
} }
function resolveGithubSearchEnabled( function resolveGithubSearchEnabled(
@@ -252,6 +256,12 @@ interface RenderAttachmentTrayArgs {
isComposerLocked: boolean; isComposerLocked: boolean;
handleOpenAttachment: (attachment: ComposerAttachment) => void; handleOpenAttachment: (attachment: ComposerAttachment) => void;
handleRemoveAttachment: (index: number) => void; handleRemoveAttachment: (index: number) => void;
labels: {
openImage: string;
removeImage: string;
openGithub: (kind: string, number: number) => string;
removeGithub: (kind: string, number: number) => string;
};
} }
function renderComposerFooter( function renderComposerFooter(
@@ -272,8 +282,13 @@ function renderComposerFooter(
} }
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null { function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
const { selectedAttachments, isComposerLocked, handleOpenAttachment, handleRemoveAttachment } = const {
args; selectedAttachments,
isComposerLocked,
handleOpenAttachment,
handleRemoveAttachment,
labels,
} = args;
if (selectedAttachments.length === 0) return null; if (selectedAttachments.length === 0) return null;
return ( return (
<View style={styles.attachmentTray} testID="composer-attachment-tray"> <View style={styles.attachmentTray} testID="composer-attachment-tray">
@@ -284,6 +299,7 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu
disabled: isComposerLocked, disabled: isComposerLocked,
onOpen: handleOpenAttachment, onOpen: handleOpenAttachment,
onRemove: handleRemoveAttachment, onRemove: handleRemoveAttachment,
labels,
}), }),
)} )}
</View> </View>
@@ -294,10 +310,13 @@ interface RenderQueueTrackArgs {
queuedMessages: readonly QueuedMessage[]; queuedMessages: readonly QueuedMessage[];
handleEditQueuedMessage: (id: string) => void; handleEditQueuedMessage: (id: string) => void;
handleSendQueuedNow: (id: string) => Promise<void>; handleSendQueuedNow: (id: string) => Promise<void>;
editLabel: string;
sendNowLabel: string;
} }
function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null { function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow } = args; const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow, editLabel, sendNowLabel } =
args;
if (queuedMessages.length === 0) return null; if (queuedMessages.length === 0) return null;
return ( return (
<View style={styles.queueTrack}> <View style={styles.queueTrack}>
@@ -307,6 +326,8 @@ function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
item={item} item={item}
onEdit={handleEditQueuedMessage} onEdit={handleEditQueuedMessage}
onSendNow={handleSendQueuedNow} onSendNow={handleSendQueuedNow}
editLabel={editLabel}
sendNowLabel={sendNowLabel}
/> />
))} ))}
</View> </View>
@@ -319,10 +340,11 @@ interface RenderComposerAttachmentPillArgs {
disabled: boolean; disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void; onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void; onRemove: (index: number) => void;
labels: RenderAttachmentTrayArgs["labels"];
} }
function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): ReactElement { function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): ReactElement {
const { attachment, index, disabled, onOpen, onRemove } = args; const { attachment, index, disabled, onOpen, onRemove, labels } = args;
if (attachment.kind === "image") { if (attachment.kind === "image") {
return ( return (
<ImageAttachmentPill <ImageAttachmentPill
@@ -332,6 +354,8 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
disabled={disabled} disabled={disabled}
onOpen={onOpen} onOpen={onOpen}
onRemove={onRemove} onRemove={onRemove}
openLabel={labels.openImage}
removeLabel={labels.removeImage}
/> />
); );
} }
@@ -352,6 +376,8 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
disabled={disabled} disabled={disabled}
onOpen={onOpen} onOpen={onOpen}
onRemove={onRemove} onRemove={onRemove}
openLabel={labels.openGithub}
removeLabel={labels.removeGithub}
/> />
); );
} }
@@ -472,9 +498,17 @@ interface QueuedMessageRowProps {
item: QueuedMessage; item: QueuedMessage;
onEdit: (id: string) => void; onEdit: (id: string) => void;
onSendNow: (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(() => { const handleEdit = useCallback(() => {
onEdit(item.id); onEdit(item.id);
}, [onEdit, item.id]); }, [onEdit, item.id]);
@@ -490,7 +524,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
<Pressable <Pressable
onPress={handleEdit} onPress={handleEdit}
style={styles.queueActionButton} style={styles.queueActionButton}
accessibilityLabel="Edit queued message" accessibilityLabel={editLabel}
accessibilityRole="button" accessibilityRole="button"
> >
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} /> <ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
@@ -498,7 +532,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
<Pressable <Pressable
onPress={handleSendNow} onPress={handleSendNow}
style={QUEUE_SEND_BUTTON_STYLE} style={QUEUE_SEND_BUTTON_STYLE}
accessibilityLabel="Send queued message now" accessibilityLabel={sendNowLabel}
accessibilityRole="button" accessibilityRole="button"
> >
<ThemedArrowUp size={ICON_SIZE.sm} uniProps={iconAccentForegroundMapping} /> <ThemedArrowUp size={ICON_SIZE.sm} uniProps={iconAccentForegroundMapping} />
@@ -523,6 +557,8 @@ interface ImageAttachmentPillProps {
disabled: boolean; disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void; onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void; onRemove: (index: number) => void;
openLabel: string;
removeLabel: string;
} }
function ImageAttachmentPill({ function ImageAttachmentPill({
@@ -531,6 +567,8 @@ function ImageAttachmentPill({
disabled, disabled,
onOpen, onOpen,
onRemove, onRemove,
openLabel,
removeLabel,
}: ImageAttachmentPillProps) { }: ImageAttachmentPillProps) {
const handleOpen = useCallback(() => { const handleOpen = useCallback(() => {
onOpen(attachment); onOpen(attachment);
@@ -543,8 +581,8 @@ function ImageAttachmentPill({
testID="composer-image-attachment-pill" testID="composer-image-attachment-pill"
onOpen={handleOpen} onOpen={handleOpen}
onRemove={handleRemove} onRemove={handleRemove}
openAccessibilityLabel="Open image attachment" openAccessibilityLabel={openLabel}
removeAccessibilityLabel="Remove image attachment" removeAccessibilityLabel={removeLabel}
disabled={disabled} disabled={disabled}
> >
<ImageAttachmentThumbnail image={attachment.metadata} /> <ImageAttachmentThumbnail image={attachment.metadata} />
@@ -558,6 +596,8 @@ interface GithubAttachmentPillProps {
disabled: boolean; disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void; onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void; onRemove: (index: number) => void;
openLabel: (kind: string, number: number) => string;
removeLabel: (kind: string, number: number) => string;
} }
function GithubAttachmentPill({ function GithubAttachmentPill({
@@ -566,6 +606,8 @@ function GithubAttachmentPill({
disabled, disabled,
onOpen, onOpen,
onRemove, onRemove,
openLabel,
removeLabel,
}: GithubAttachmentPillProps) { }: GithubAttachmentPillProps) {
const item = attachment.item; const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue"; const kindLabel = item.kind === "pr" ? "PR" : "issue";
@@ -580,8 +622,8 @@ function GithubAttachmentPill({
testID="composer-github-attachment-pill" testID="composer-github-attachment-pill"
onOpen={handleOpen} onOpen={handleOpen}
onRemove={handleRemove} onRemove={handleRemove}
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`} openAccessibilityLabel={openLabel(kindLabel, item.number)}
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`} removeAccessibilityLabel={removeLabel(kindLabel, item.number)}
disabled={disabled} disabled={disabled}
> >
<View style={styles.githubPillBody}> <View style={styles.githubPillBody}>
@@ -693,8 +735,6 @@ interface ComposerProps {
} }
const EMPTY_ARRAY: readonly QueuedMessage[] = []; 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); const StableMessageInput = memo(MessageInput);
function resolveContextWindowValues( function resolveContextWindowValues(
@@ -714,6 +754,7 @@ interface ComposerCancelButtonProps {
isConnected: boolean; isConnected: boolean;
isCancellingAgent: boolean; isCancellingAgent: boolean;
agentInterruptKeys: ReturnType<typeof useShortcutKeys>; agentInterruptKeys: ReturnType<typeof useShortcutKeys>;
t: TFunction;
} }
function ComposerCancelButton({ function ComposerCancelButton({
@@ -723,8 +764,11 @@ function ComposerCancelButton({
isConnected, isConnected,
isCancellingAgent, isCancellingAgent,
agentInterruptKeys, agentInterruptKeys,
t,
}: ComposerCancelButtonProps) { }: ComposerCancelButtonProps) {
const accessibilityLabel = isCancellingAgent ? "Canceling agent" : "Stop agent"; const accessibilityLabel = isCancellingAgent
? t("composer.cancel.cancelingAgent")
: t("composer.cancel.stopAgent");
const icon = isCancellingAgent ? ( const icon = isCancellingAgent ? (
<ActivityIndicator size="small" color="white" /> <ActivityIndicator size="small" color="white" />
) : ( ) : (
@@ -744,7 +788,7 @@ function ComposerCancelButton({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text> <Text style={styles.tooltipText}>{t("composer.cancel.interrupt")}</Text>
{shortcutNode} {shortcutNode}
</View> </View>
</TooltipContent> </TooltipContent>
@@ -777,6 +821,7 @@ interface ComposerVoiceModeButtonProps {
state: PressableStateCallbackType & { hovered?: boolean }, state: PressableStateCallbackType & { hovered?: boolean },
) => (object | undefined)[]; ) => (object | undefined)[];
voiceToggleKeys: ReturnType<typeof useShortcutKeys>; voiceToggleKeys: ReturnType<typeof useShortcutKeys>;
t: TFunction;
} }
interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps { interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
@@ -819,6 +864,7 @@ function ComposerVoiceModeButton({
isVoiceSwitching, isVoiceSwitching,
realtimeVoiceButtonStyle, realtimeVoiceButtonStyle,
voiceToggleKeys, voiceToggleKeys,
t,
}: ComposerVoiceModeButtonProps) { }: ComposerVoiceModeButtonProps) {
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null; const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
const renderTriggerContent = useCallback( const renderTriggerContent = useCallback(
@@ -836,7 +882,7 @@ function ComposerVoiceModeButton({
<TooltipTrigger <TooltipTrigger
onPress={handleToggleRealtimeVoice} onPress={handleToggleRealtimeVoice}
disabled={!isConnected || isVoiceSwitching} disabled={!isConnected || isVoiceSwitching}
accessibilityLabel="Enable Voice mode" accessibilityLabel={t("composer.voice.enableVoiceMode")}
accessibilityRole="button" accessibilityRole="button"
style={realtimeVoiceButtonStyle} style={realtimeVoiceButtonStyle}
> >
@@ -844,7 +890,7 @@ function ComposerVoiceModeButton({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text> <Text style={styles.tooltipText}>{t("composer.voice.voiceMode")}</Text>
{shortcutNode} {shortcutNode}
</View> </View>
</TooltipContent> </TooltipContent>
@@ -887,6 +933,7 @@ export function Composer({
externalKeyboardShift, externalKeyboardShift,
isCompactLayout: isCompactLayoutOverride, isCompactLayout: isCompactLayoutOverride,
}: ComposerProps) { }: ComposerProps) {
const { t } = useTranslation();
const buttonIconSize = resolveComposerButtonIconSize(); const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
@@ -920,7 +967,7 @@ export function Composer({
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout); const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t);
const userInput = value; const userInput = value;
const setUserInput = onChangeText; const setUserInput = onChangeText;
const { const {
@@ -1070,11 +1117,11 @@ export function Composer({
return; return;
} }
if (!sendAgentMessageRef.current) { if (!sendAgentMessageRef.current) {
throw new Error("Host is not connected"); throw new Error(t("workspace.terminal.hostDisconnected"));
} }
await sendAgentMessageRef.current(agentIdRef.current, text, submitAttachments); await sendAgentMessageRef.current(agentIdRef.current, text, submitAttachments);
}, },
[cwd, onMessageSent], [cwd, onMessageSent, t],
); );
useEffect(() => { useEffect(() => {
@@ -1088,7 +1135,7 @@ export function Composer({
sendAttachments: ComposerAttachment[], sendAttachments: ComposerAttachment[],
) => { ) => {
if (!client) { if (!client) {
throw new Error("Host is not connected"); throw new Error(t("workspace.terminal.hostDisconnected"));
} }
const stream: AgentStreamWriter = { const stream: AgentStreamWriter = {
getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id), getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id),
@@ -1106,7 +1153,7 @@ export function Composer({
}); });
onAttentionPromptSend?.(); onAttentionPromptSend?.();
}; };
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead]); }, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead, t]);
useEffect(() => { useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage; onSubmitMessageRef.current = onSubmitMessage;
@@ -1181,6 +1228,7 @@ export function Composer({
onSubmitError: (error) => { onSubmitError: (error) => {
console.error("[AgentInput] Failed to send message:", error); console.error("[AgentInput] Failed to send message:", error);
}, },
failedToSendMessage: t("composer.errors.failedToSend"),
}); });
completeSubmit({ completeSubmit({
result, result,
@@ -1198,6 +1246,7 @@ export function Composer({
setUserInput, setUserInput,
submitBehavior, submitBehavior,
submitMessage, submitMessage,
t,
], ],
); );
@@ -1376,12 +1425,13 @@ export function Composer({
queue: queueWriter, queue: queueWriter,
submitMessage: ({ text, attachments: queuedAttachments }) => submitMessage: ({ text, attachments: queuedAttachments }) =>
submitMessage(text, queuedAttachments), submitMessage(text, queuedAttachments),
failedToSendMessage: t("composer.errors.failedToSend"),
}); });
if (result.status === "failed") { if (result.status === "failed") {
setSendError(result.errorMessage); setSendError(result.errorMessage);
} }
}, },
[agentId, queueWriter, submitMessage], [agentId, queueWriter, submitMessage, t],
); );
const handleQueue = useCallback( const handleQueue = useCallback(
@@ -1433,6 +1483,7 @@ export function Composer({
isConnected={isConnected} isConnected={isConnected}
isCancellingAgent={isCancellingAgent} isCancellingAgent={isCancellingAgent}
agentInterruptKeys={agentInterruptKeys} agentInterruptKeys={agentInterruptKeys}
t={t}
/> />
), ),
[ [
@@ -1445,6 +1496,7 @@ export function Composer({
isCancellingAgent, isCancellingAgent,
isConnected, isConnected,
isProcessing, isProcessing,
t,
], ],
); );
@@ -1463,6 +1515,7 @@ export function Composer({
isVoiceSwitching={isVoiceSwitching} isVoiceSwitching={isVoiceSwitching}
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle} realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
voiceToggleKeys={voiceToggleKeys} voiceToggleKeys={voiceToggleKeys}
t={t}
cancelButton={cancelButton} cancelButton={cancelButton}
/> />
), ),
@@ -1479,6 +1532,7 @@ export function Composer({
isVoiceModeForAgent, isVoiceModeForAgent,
isVoiceSwitching, isVoiceSwitching,
realtimeVoiceButtonStyle, realtimeVoiceButtonStyle,
t,
voiceToggleKeys, voiceToggleKeys,
], ],
); );
@@ -1528,7 +1582,7 @@ export function Composer({
() => [ () => [
{ {
id: "image", id: "image",
label: "Add image", label: t("composer.attachments.addImage"),
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />, icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => { onSelect: () => {
void handlePickImage(); void handlePickImage();
@@ -1536,14 +1590,14 @@ export function Composer({
}, },
{ {
id: "github", id: "github",
label: "Add issue or PR", label: t("composer.attachments.addIssueOrPr"),
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />, icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => { onSelect: () => {
setIsGithubPickerOpen(true); setIsGithubPickerOpen(true);
}, },
}, },
], ],
[handlePickImage], [handlePickImage, t],
); );
const handleToggleGithubItem = useCallback( const handleToggleGithubItem = useCallback(
@@ -1641,13 +1695,28 @@ export function Composer({
isComposerLocked, isComposerLocked,
handleOpenAttachment, handleOpenAttachment,
handleRemoveAttachment, 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( 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); const messageInputContainerRef = useRef<View>(null);
@@ -1660,8 +1729,8 @@ export function Composer({
[sendError], [sendError],
); );
const githubEmptyText = githubSearchResultsQuery.isFetching const githubEmptyText = githubSearchResultsQuery.isFetching
? "Searching..." ? t("composer.github.searching")
: "No results found."; : t("composer.github.noResults");
const autocompleteVisible = autocomplete.isVisible && isPaneFocused; const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
return ( return (
@@ -1732,8 +1801,8 @@ export function Composer({
onSelect={noop} onSelect={noop}
keepOpenOnSelect keepOpenOnSelect
searchable searchable
searchPlaceholder="Search issues and PRs..." searchPlaceholder={t("composer.github.searchPlaceholder")}
title="Attach issue or PR" title={t("composer.github.title")}
open={isGithubPickerOpen} open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange} onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery} onSearchQueryChange={setGithubSearchQuery}
+47 -65
View File
@@ -22,6 +22,7 @@ import {
forwardRef, forwardRef,
} from "react"; } from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; 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 { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerHeightMirror } from "./height-mirror"; import { useComposerHeightMirror } from "./height-mirror";
import {
resolveSendTooltipLabel,
resolveSubmitAccessibilityLabel,
resolveVoiceAccessibilityLabel,
resolveVoiceTooltipText,
} from "./labels";
import { computeCanStartDictation } from "./state"; import { computeCanStartDictation } from "./state";
export interface AttachmentMenuItem { export interface AttachmentMenuItem {
@@ -142,7 +149,6 @@ const MIN_INPUT_HEIGHT_DESKTOP = 46;
const DEFAULT_MAX_INPUT_HEIGHT = 160; const DEFAULT_MAX_INPUT_HEIGHT = 160;
const MAX_INPUT_VIEWPORT_RATIO = 0.5; const MAX_INPUT_VIEWPORT_RATIO = 0.5;
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE; 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%"]; const ATTACHMENT_SHEET_SNAP_POINTS = ["34%", "45%"];
type WebTextInputKeyPressEvent = NativeSyntheticEvent< type WebTextInputKeyPressEvent = NativeSyntheticEvent<
@@ -259,18 +265,24 @@ function AttachmentDropdown({
attachButtonStyle, attachButtonStyle,
renderAttachButtonIcon, renderAttachButtonIcon,
attachmentMenuItems, attachmentMenuItems,
addAttachmentLabel,
}: { }: {
isConnected: boolean; isConnected: boolean;
disabled: boolean; disabled: boolean;
attachButtonStyle: React.ComponentProps<typeof DropdownMenuTrigger>["style"]; attachButtonStyle: React.ComponentProps<typeof DropdownMenuTrigger>["style"];
renderAttachButtonIcon: (input: { hovered?: boolean }) => React.ReactElement; renderAttachButtonIcon: (input: { hovered?: boolean }) => React.ReactElement;
attachmentMenuItems: AttachmentMenuItem[]; attachmentMenuItems: AttachmentMenuItem[];
addAttachmentLabel: string;
}) { }) {
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const [isSheetOpen, setIsSheetOpen] = useState(false); const [isSheetOpen, setIsSheetOpen] = useState(false);
useDismissKeyboardOnOpen(isSheetOpen, isCompact); useDismissKeyboardOnOpen(isSheetOpen, isCompact);
const isButtonDisabled = !isConnected || disabled; const isButtonDisabled = !isConnected || disabled;
const attachmentSheetHeader = useMemo<SheetHeader>(
() => ({ title: addAttachmentLabel }),
[addAttachmentLabel],
);
const handleOpenSheet = useCallback(() => { const handleOpenSheet = useCallback(() => {
if (isButtonDisabled) return; if (isButtonDisabled) return;
setIsSheetOpen(true); setIsSheetOpen(true);
@@ -306,7 +318,7 @@ function AttachmentDropdown({
<> <>
<Pressable <Pressable
disabled={isButtonDisabled} disabled={isButtonDisabled}
accessibilityLabel="Add attachment" accessibilityLabel={addAttachmentLabel}
accessibilityRole="button" accessibilityRole="button"
testID="message-input-attach-button" testID="message-input-attach-button"
onPress={handleOpenSheet} onPress={handleOpenSheet}
@@ -315,7 +327,7 @@ function AttachmentDropdown({
{renderMobileAttachButtonIcon} {renderMobileAttachButtonIcon}
</Pressable> </Pressable>
<AdaptiveModalSheet <AdaptiveModalSheet
header={ATTACHMENT_SHEET_HEADER} header={attachmentSheetHeader}
visible={isSheetOpen} visible={isSheetOpen}
onClose={handleCloseSheet} onClose={handleCloseSheet}
snapPoints={ATTACHMENT_SHEET_SNAP_POINTS} snapPoints={ATTACHMENT_SHEET_SNAP_POINTS}
@@ -333,7 +345,7 @@ function AttachmentDropdown({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={isButtonDisabled} disabled={isButtonDisabled}
accessibilityLabel="Add attachment" accessibilityLabel={addAttachmentLabel}
accessibilityRole="button" accessibilityRole="button"
testID="message-input-attach-button" testID="message-input-attach-button"
style={attachButtonStyle} style={attachButtonStyle}
@@ -342,7 +354,7 @@ function AttachmentDropdown({
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Add attachment</Text> <Text style={styles.tooltipText}>{addAttachmentLabel}</Text>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenuContent <DropdownMenuContent
@@ -429,49 +441,6 @@ function SendButtonContent({
return <ThemedArrowUp size={buttonIconSize} uniProps={iconAccentForegroundMapping} />; 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 { interface DesktopKeyPressContext {
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined;
submitOnEnter: boolean; submitOnEnter: boolean;
@@ -749,14 +718,16 @@ function MessageInputOverlay({
function FocusHint({ function FocusHint({
visible, visible,
focusInputKeys, focusInputKeys,
label,
}: { }: {
visible: boolean; visible: boolean;
focusInputKeys: ShortcutChord | null | undefined; focusInputKeys: ShortcutChord | null | undefined;
label: string;
}) { }) {
if (!visible || !focusInputKeys) return null; if (!visible || !focusInputKeys || !label.trim()) return null;
return ( return (
<Text style={styles.focusHintText} pointerEvents="none"> <Text style={styles.focusHintText} pointerEvents="none">
{formatShortcut(focusInputKeys[0], getShortcutOs())} to focus {label}
</Text> </Text>
); );
} }
@@ -812,9 +783,8 @@ function SendButtonTooltip({
isSubmitLoading, isSubmitLoading,
submitIcon, submitIcon,
buttonIconSize, buttonIconSize,
submitButtonAccessibilityLabel,
defaultActionQueues,
sendKeys, sendKeys,
sendTooltipLabel,
}: { }: {
shouldShow: boolean; shouldShow: boolean;
canPressLoadingButton: boolean; canPressLoadingButton: boolean;
@@ -826,9 +796,8 @@ function SendButtonTooltip({
isSubmitLoading: boolean; isSubmitLoading: boolean;
submitIcon: "arrow" | "return"; submitIcon: "arrow" | "return";
buttonIconSize: number; buttonIconSize: number;
submitButtonAccessibilityLabel: string | undefined;
defaultActionQueues: boolean;
sendKeys: ShortcutChord | null | undefined; sendKeys: ShortcutChord | null | undefined;
sendTooltipLabel: string;
}) { }) {
if (!shouldShow) return null; if (!shouldShow) return null;
return ( return (
@@ -847,10 +816,7 @@ function SendButtonTooltip({
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<SendTooltipBody <SendTooltipBody label={sendTooltipLabel} sendKeys={sendKeys} />
label={resolveSendTooltipLabel({ submitButtonAccessibilityLabel, defaultActionQueues })}
sendKeys={sendKeys}
/>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );
@@ -908,6 +874,7 @@ interface ToggleRealtimeVoiceContext {
isAgentRunning: boolean; isAgentRunning: boolean;
handleStopRealtimeVoice: () => Promise<unknown> | void; handleStopRealtimeVoice: () => Promise<unknown> | void;
toast: { error: (msg: string) => void }; toast: { error: (msg: string) => void };
interruptBeforeVoiceMessage: string;
} }
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
@@ -920,7 +887,7 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
return; return;
} }
if (ctx.isAgentRunning) { if (ctx.isAgentRunning) {
ctx.toast.error("Interrupt the agent before starting voice mode"); ctx.toast.error(ctx.interruptBeforeVoiceMessage);
return; return;
} }
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => { void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
@@ -1199,7 +1166,7 @@ interface ResolvedMessageInputProps {
onAddImages: ((images: ImageAttachment[]) => void) | undefined; onAddImages: ((images: ImageAttachment[]) => void) | undefined;
client: DaemonClient | null; client: DaemonClient | null;
isReadyForDictation: boolean | undefined; isReadyForDictation: boolean | undefined;
placeholder: string; placeholder: string | undefined;
autoFocus: boolean; autoFocus: boolean;
autoFocusKey: string | undefined; autoFocusKey: string | undefined;
disabled: boolean; disabled: boolean;
@@ -1239,7 +1206,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
onAddImages: props.onAddImages, onAddImages: props.onAddImages,
client: props.client, client: props.client,
isReadyForDictation: props.isReadyForDictation, isReadyForDictation: props.isReadyForDictation,
placeholder: props.placeholder ?? "Message...", placeholder: props.placeholder,
autoFocus: props.autoFocus ?? false, autoFocus: props.autoFocus ?? false,
autoFocusKey: props.autoFocusKey, autoFocusKey: props.autoFocusKey,
disabled: props.disabled ?? false, disabled: props.disabled ?? false,
@@ -1308,6 +1275,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
inputWrapperStyle, inputWrapperStyle,
attachmentSlot, attachmentSlot,
} = resolveMessageInputProps(props); } = resolveMessageInputProps(props);
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const { height: windowHeight } = useWindowDimensions(); const { height: windowHeight } = useWindowDimensions();
const maxInputHeight = resolveMaxInputHeight(windowHeight); const maxInputHeight = resolveMaxInputHeight(windowHeight);
@@ -1564,12 +1532,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isAgentRunning, isAgentRunning,
handleStopRealtimeVoice, handleStopRealtimeVoice,
toast, toast,
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
}); });
}, [ }, [
disabled, disabled,
handleStopRealtimeVoice, handleStopRealtimeVoice,
isAgentRunning, isAgentRunning,
isConnected, isConnected,
t,
toast, toast,
voice, voice,
voiceAgentId, voiceAgentId,
@@ -1747,17 +1717,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton, canPressLoadingButton,
defaultActionQueues, defaultActionQueues,
isAgentRunning, isAgentRunning,
t,
}); });
const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({ const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent, isRealtimeVoiceForCurrentAgent,
isMuted: Boolean(voice?.isMuted), isMuted: Boolean(voice?.isMuted),
isDictating, isDictating,
t,
}); });
const voiceTooltipText = resolveVoiceTooltipText({ const voiceTooltipText = resolveVoiceTooltipText({
isRealtimeVoiceForCurrentAgent, isRealtimeVoiceForCurrentAgent,
isMuted: Boolean(voice?.isMuted), isMuted: Boolean(voice?.isMuted),
t,
});
const sendTooltipLabel = resolveSendTooltipLabel({
submitButtonAccessibilityLabel,
defaultActionQueues,
t,
}); });
const handleInputChange = useCallback( const handleInputChange = useCallback(
@@ -1854,9 +1833,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
ref={textInputRef} ref={textInputRef}
value={value} value={value}
onChangeText={handleInputChange} onChangeText={handleInputChange}
placeholder={placeholder} placeholder={placeholder ?? t("composer.placeholders.fallback")}
uniProps={textInputPlaceholderColorMapping} uniProps={textInputPlaceholderColorMapping}
accessibilityLabel="Message agent..." accessibilityLabel={t("composer.input.accessibilityLabel")}
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}
style={textInputStyle} style={textInputStyle}
@@ -1872,6 +1851,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
<FocusHint <FocusHint
visible={isWeb && isPaneFocused && !isInputFocused && !value} visible={isWeb && isPaneFocused && !isInputFocused && !value}
focusInputKeys={focusInputKeys} focusInputKeys={focusInputKeys}
label={t("composer.input.focusHint", {
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
})}
/> />
</View> </View>
@@ -1885,6 +1867,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
attachButtonStyle={attachButtonStyle} attachButtonStyle={attachButtonStyle}
renderAttachButtonIcon={renderAttachButtonIcon} renderAttachButtonIcon={renderAttachButtonIcon}
attachmentMenuItems={attachmentMenuItems} attachmentMenuItems={attachmentMenuItems}
addAttachmentLabel={t("composer.input.addAttachment")}
/> />
{leftContent} {leftContent}
</View> </View>
@@ -1915,9 +1898,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isSubmitLoading={isSubmitLoading} isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon} submitIcon={submitIcon}
buttonIconSize={buttonIconSize} buttonIconSize={buttonIconSize}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
defaultActionQueues={defaultActionQueues}
sendKeys={sendKeys} sendKeys={sendKeys}
sendTooltipLabel={sendTooltipLabel}
/> />
</View> </View>
</View> </View>
@@ -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");
});
});
+54
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");
}
+8 -1
View File
@@ -1,3 +1,5 @@
import { i18n } from "@/i18n/i18next";
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed"; export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
export interface AgentInputSubmitActionInput<TAttachment> { export interface AgentInputSubmitActionInput<TAttachment> {
@@ -17,6 +19,7 @@ export interface AgentInputSubmitActionInput<TAttachment> {
setSendError: (message: string | null) => void; setSendError: (message: string | null) => void;
setIsProcessing: (isProcessing: boolean) => void; setIsProcessing: (isProcessing: boolean) => void;
onSubmitError?: (error: unknown) => void; onSubmitError?: (error: unknown) => void;
failedToSendMessage?: string;
} }
export async function submitAgentInput<TAttachment>( export async function submitAgentInput<TAttachment>(
@@ -67,7 +70,11 @@ export async function submitAgentInput<TAttachment>(
input.setUserInput(trimmedMessage); input.setUserInput(trimmedMessage);
input.setAttachments(attachments); 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); input.setIsProcessing(false);
return "failed"; return "failed";
} }
@@ -2,6 +2,7 @@ import { useRef, ReactNode, useCallback, useEffect } from "react";
import { Buffer } from "buffer"; import { Buffer } from "buffer";
import { AppState } from "react-native"; import { AppState } from "react-native";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useClientActivity } from "@/hooks/use-client-activity"; import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
@@ -440,6 +441,7 @@ export function SessionProvider(props: SessionProviderProps) {
} }
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) { function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
const { t } = useTranslation();
const voiceRuntime = useVoiceRuntimeOptional(); const voiceRuntime = useVoiceRuntimeOptional();
const voiceAudioEngine = useVoiceAudioEngineOptional(); const voiceAudioEngine = useVoiceAudioEngineOptional();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -908,25 +910,25 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId, serverId,
setVoiceMode: async (enabled, agentId) => { setVoiceMode: async (enabled, agentId) => {
if (!client) { if (!client) {
throw new Error("Daemon unavailable"); throw new Error(t("common.errors.daemonUnavailable"));
} }
await client.setVoiceMode(enabled, agentId); await client.setVoiceMode(enabled, agentId);
}, },
sendVoiceAudioChunk: async (audioData, mimeType) => { sendVoiceAudioChunk: async (audioData, mimeType) => {
if (!client) { if (!client) {
throw new Error("Daemon unavailable"); throw new Error(t("common.errors.daemonUnavailable"));
} }
await client.sendVoiceAudioChunk(audioData, mimeType); await client.sendVoiceAudioChunk(audioData, mimeType);
}, },
audioPlayed: async (chunkId) => { audioPlayed: async (chunkId) => {
if (!client) { if (!client) {
throw new Error("Daemon unavailable"); throw new Error(t("common.errors.daemonUnavailable"));
} }
await client.audioPlayed(chunkId); await client.audioPlayed(chunkId);
}, },
abortRequest: async () => { abortRequest: async () => {
if (!client) { if (!client) {
throw new Error("Daemon unavailable"); throw new Error(t("common.errors.daemonUnavailable"));
} }
await client.abortRequest(); await client.abortRequest();
}, },
@@ -935,7 +937,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}, },
}); });
return () => unregister?.(); return () => unregister?.();
}, [client, serverId, setIsPlayingAudio, voiceRuntime]); }, [client, serverId, setIsPlayingAudio, t, voiceRuntime]);
useEffect(() => { useEffect(() => {
voiceRuntime?.updateSessionConnection(serverId, isConnected); voiceRuntime?.updateSessionConnection(serverId, isConnected);
@@ -10,6 +10,12 @@ export interface DesktopPermissionRowProps {
title: string; title: string;
status: DesktopPermissionStatus | null; status: DesktopPermissionStatus | null;
isRequesting: boolean; isRequesting: boolean;
labels: {
granted: string;
request: string;
requesting: string;
busyExtraAction: (label: string) => string;
};
showBorder?: boolean; showBorder?: boolean;
onRequest: () => void; onRequest: () => void;
extraActionLabel?: string; extraActionLabel?: string;
@@ -22,6 +28,7 @@ export function DesktopPermissionRow({
title, title,
status, status,
isRequesting, isRequesting,
labels,
showBorder, showBorder,
onRequest, onRequest,
extraActionLabel, extraActionLabel,
@@ -54,7 +61,7 @@ export function DesktopPermissionRow({
<View style={styles.permissionGrantedActions}> <View style={styles.permissionGrantedActions}>
<View style={styles.permissionStatusPill}> <View style={styles.permissionStatusPill}>
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.permissionStatusText}>Granted</Text> <Text style={styles.permissionStatusText}>{labels.granted}</Text>
</View> </View>
{extraActionLabel && onExtraAction ? ( {extraActionLabel && onExtraAction ? (
<Button <Button
@@ -63,13 +70,13 @@ export function DesktopPermissionRow({
onPress={onExtraAction} onPress={onExtraAction}
disabled={isExtraActionDisabled || isExtraActionBusy} disabled={isExtraActionDisabled || isExtraActionBusy}
> >
{isExtraActionBusy ? `${extraActionLabel}...` : extraActionLabel} {isExtraActionBusy ? labels.busyExtraAction(extraActionLabel) : extraActionLabel}
</Button> </Button>
) : null} ) : null}
</View> </View>
) : ( ) : (
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}> <Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
{isRequesting ? "Requesting..." : "Request"} {isRequesting ? labels.requesting : labels.request}
</Button> </Button>
)} )}
{shouldShowDetail ? ( {shouldShowDetail ? (
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { RotateCw } from "lucide-react-native"; import { RotateCw } from "lucide-react-native";
@@ -9,6 +10,7 @@ import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
export function DesktopPermissionsSection() { export function DesktopPermissionsSection() {
const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { const {
isDesktopApp, isDesktopApp,
@@ -59,12 +61,22 @@ export function DesktopPermissionsSection() {
leftIcon={refreshIcon} leftIcon={refreshIcon}
onPress={handleRefreshPress} onPress={handleRefreshPress}
disabled={isBusy} disabled={isBusy}
accessibilityLabel="Refresh desktop permissions" accessibilityLabel={t("settings.permissions.refreshAccessibility")}
> >
{isRefreshing ? "Refreshing..." : "Refresh"} {isRefreshing ? t("settings.permissions.refreshing") : t("settings.permissions.refresh")}
</Button> </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) { if (!isDesktopApp) {
@@ -72,25 +84,27 @@ export function DesktopPermissionsSection() {
} }
return ( return (
<SettingsSection title="Permissions" trailing={refreshButton}> <SettingsSection title={t("settings.permissions.title")} trailing={refreshButton}>
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
<DesktopPermissionRow <DesktopPermissionRow
title="Notifications" title={t("settings.permissions.notifications")}
status={snapshot?.notifications ?? null} status={snapshot?.notifications ?? null}
isRequesting={requestingPermission === "notifications"} isRequesting={requestingPermission === "notifications"}
onRequest={handleRequestNotifications} onRequest={handleRequestNotifications}
extraActionLabel="Test" labels={permissionLabels}
extraActionLabel={t("settings.permissions.test")}
isExtraActionBusy={isSendingTestNotification} isExtraActionBusy={isSendingTestNotification}
isExtraActionDisabled={!notificationsGranted || isBusy} isExtraActionDisabled={!notificationsGranted || isBusy}
onExtraAction={handleSendTestNotification} onExtraAction={handleSendTestNotification}
/> />
{testNotificationError ? <Text style={errorTextStyle}>{testNotificationError}</Text> : null} {testNotificationError ? <Text style={errorTextStyle}>{testNotificationError}</Text> : null}
<DesktopPermissionRow <DesktopPermissionRow
title="Microphone" title={t("settings.permissions.microphone")}
showBorder showBorder
status={snapshot?.microphone ?? null} status={snapshot?.microphone ?? null}
isRequesting={requestingPermission === "microphone"} isRequesting={requestingPermission === "microphone"}
onRequest={handleRequestMicrophone} onRequest={handleRequestMicrophone}
labels={permissionLabels}
/> />
</View> </View>
</SettingsSection> </SettingsSection>
@@ -1,6 +1,7 @@
import React, { type ReactElement, useCallback, useMemo, useState } from "react"; import React, { type ReactElement, useCallback, useMemo, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native"; import { ActivityIndicator, Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
@@ -41,6 +42,7 @@ function useKeepRunningAfterQuitToggle(args: {
} }
function useDaemonCliStatusModal() { function useDaemonCliStatusModal() {
const { t } = useTranslation();
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null); const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false); const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
@@ -52,12 +54,12 @@ function useDaemonCliStatusModal() {
setIsCliStatusModalOpen(true); setIsCliStatusModalOpen(true);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(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); setIsCliStatusModalOpen(true);
} finally { } finally {
setIsLoadingCliStatus(false); setIsLoadingCliStatus(false);
} }
}, []); }, [t]);
const handleCopyCliStatus = useCallback(() => { const handleCopyCliStatus = useCallback(() => {
if (!cliStatusOutput) { if (!cliStatusOutput) {
@@ -65,13 +67,13 @@ function useDaemonCliStatusModal() {
} }
void Clipboard.setStringAsync(cliStatusOutput) void Clipboard.setStringAsync(cliStatusOutput)
.then(() => { .then(() => {
Alert.alert("Copied", "Status copied to clipboard."); Alert.alert(t("common.states.copied"), t("desktop.daemon.fullStatus.copied"));
return; return;
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to copy daemon status", error); console.error("[Settings] Failed to copy daemon status", error);
}); });
}, [cliStatusOutput]); }, [cliStatusOutput, t]);
const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []); const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []);
@@ -86,6 +88,7 @@ function useDaemonCliStatusModal() {
} }
function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) { function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) {
const { t } = useTranslation();
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const handleCopyLogPath = useCallback(() => { const handleCopyLogPath = useCallback(() => {
@@ -96,14 +99,14 @@ function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) {
void Clipboard.setStringAsync(logPath) void Clipboard.setStringAsync(logPath)
.then(() => { .then(() => {
Alert.alert("Copied", "Log path copied."); Alert.alert(t("common.states.copied"), t("desktop.daemon.logs.copied"));
return; return;
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to copy log path", 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(() => { const handleOpenLogs = useCallback(() => {
if (!daemonLogs) { if (!daemonLogs) {
@@ -124,18 +127,23 @@ interface DaemonLogsModalProps {
} }
function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps) { function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps) {
const { t } = useTranslation();
const header = useMemo<SheetHeader>(() => ({ title: t("desktop.daemon.logs.modalTitle") }), [t]);
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
header={DAEMON_LOGS_HEADER} header={header}
testID="managed-daemon-logs-dialog" testID="managed-daemon-logs-dialog"
snapPoints={LOGS_MODAL_SNAP_POINTS} snapPoints={LOGS_MODAL_SNAP_POINTS}
> >
<View style={styles.modalBody}> <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}> <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> </Text>
</View> </View>
</AdaptiveModalSheet> </AdaptiveModalSheet>
@@ -155,11 +163,17 @@ function DaemonCliStatusModal({
cliStatusOutput, cliStatusOutput,
onCopy, onCopy,
}: DaemonCliStatusModalProps) { }: DaemonCliStatusModalProps) {
const { t } = useTranslation();
const header = useMemo<SheetHeader>(
() => ({ title: t("desktop.daemon.fullStatus.modalTitle") }),
[t],
);
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
header={DAEMON_STATUS_HEADER} header={header}
testID="daemon-cli-status-dialog" testID="daemon-cli-status-dialog"
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS} snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
> >
@@ -169,10 +183,10 @@ function DaemonCliStatusModal({
</Text> </Text>
<View style={styles.modalActions}> <View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={onClose}> <Button variant="outline" size="sm" onPress={onClose}>
Close {t("common.actions.close")}
</Button> </Button>
<Button size="sm" onPress={onCopy}> <Button size="sm" onPress={onCopy}>
Copy {t("common.actions.copy")}
</Button> </Button>
</View> </View>
</View> </View>
@@ -200,6 +214,7 @@ interface DaemonInfoCardProps {
} }
function DaemonInfoCard(props: DaemonInfoCardProps) { function DaemonInfoCard(props: DaemonInfoCardProps) {
const { t } = useTranslation();
const { const {
daemonStatusStateText, daemonStatusStateText,
daemonStatusDetailText, daemonStatusDetailText,
@@ -223,8 +238,8 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
<View style={settingsStyles.row}> <View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text> <Text style={settingsStyles.rowTitle}>{t("desktop.daemon.status.title")}</Text>
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here</Text> <Text style={settingsStyles.rowHint}>{t("desktop.daemon.status.builtInOnly")}</Text>
</View> </View>
<View style={styles.statusValueGroup}> <View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text> <Text style={styles.valueText}>{daemonStatusStateText}</Text>
@@ -233,39 +248,39 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
</View> </View>
<View style={ROW_WITH_BORDER_STYLE}> <View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Manage built-in daemon</Text> <Text style={settingsStyles.rowTitle}>{t("desktop.daemon.management.title")}</Text>
<Text style={settingsStyles.rowHint}>Let Paseo start and stop the built-in daemon</Text> <Text style={settingsStyles.rowHint}>{t("desktop.daemon.management.hint")}</Text>
</View> </View>
<Switch <Switch
value={!isDaemonManagementPaused} value={!isDaemonManagementPaused}
onValueChange={handleToggleDaemonManagement} onValueChange={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement} disabled={isUpdatingDaemonManagement}
accessibilityLabel="Manage built-in daemon" accessibilityLabel={t("desktop.daemon.management.title")}
/> />
</View> </View>
<View style={ROW_WITH_BORDER_STYLE}> <View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Keep daemon running after quit</Text> <Text style={settingsStyles.rowTitle}>{t("desktop.daemon.keepRunning.title")}</Text>
<Text style={settingsStyles.rowHint}>Daemon keeps running when you quit Paseo</Text> <Text style={settingsStyles.rowHint}>{t("desktop.daemon.keepRunning.hint")}</Text>
</View> </View>
<Switch <Switch
value={keepRunningAfterQuit} value={keepRunningAfterQuit}
onValueChange={handleToggleKeepRunningAfterQuit} onValueChange={handleToggleKeepRunningAfterQuit}
disabled={isUpdatingKeepRunningAfterQuit} disabled={isUpdatingKeepRunningAfterQuit}
accessibilityLabel="Keep daemon running after quit" accessibilityLabel={t("desktop.daemon.keepRunning.title")}
/> />
</View> </View>
<View style={ROW_WITH_BORDER_STYLE}> <View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}> <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}> <Text style={settingsStyles.rowHint}>
{daemonLogs?.logPath ?? "Log path unavailable"} {daemonLogs?.logPath ?? t("desktop.daemon.logs.unavailable")}
</Text> </Text>
</View> </View>
<View style={styles.actionGroup}> <View style={styles.actionGroup}>
{daemonLogs?.logPath ? ( {daemonLogs?.logPath ? (
<Button variant="outline" size="sm" leftIcon={copyIcon} onPress={handleCopyLogPath}> <Button variant="outline" size="sm" leftIcon={copyIcon} onPress={handleCopyLogPath}>
Copy path {t("desktop.daemon.logs.copyPath")}
</Button> </Button>
) : null} ) : null}
<Button <Button
@@ -275,16 +290,14 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
onPress={handleOpenLogs} onPress={handleOpenLogs}
disabled={!daemonLogs} disabled={!daemonLogs}
> >
Open logs {t("desktop.daemon.logs.open")}
</Button> </Button>
</View> </View>
</View> </View>
<View style={ROW_WITH_BORDER_STYLE}> <View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text> <Text style={settingsStyles.rowTitle}>{t("desktop.daemon.fullStatus.title")}</Text>
<Text style={settingsStyles.rowHint}> <Text style={settingsStyles.rowHint}>{t("desktop.daemon.fullStatus.hint")}</Text>
Runs `paseo daemon status` and shows the output
</Text>
</View> </View>
<Button <Button
variant="outline" variant="outline"
@@ -293,7 +306,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
onPress={handleRunCliStatus} onPress={handleRunCliStatus}
disabled={isLoadingCliStatus} disabled={isLoadingCliStatus}
> >
{isLoadingCliStatus ? "Loading..." : "View status"} {isLoadingCliStatus ? t("common.states.loading") : t("desktop.daemon.fullStatus.view")}
</Button> </Button>
</View> </View>
</View> </View>
@@ -301,6 +314,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
} }
export function LocalDaemonSection() { export function LocalDaemonSection() {
const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon(); const showSection = shouldUseDesktopDaemon();
const appVersion = resolveAppVersion(); const appVersion = resolveAppVersion();
@@ -318,8 +332,13 @@ export function LocalDaemonSection() {
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion); const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
const daemonStatusStateText = const daemonStatusStateText =
statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running"); statusError ??
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`; (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 isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon;
const { isUpdating: isUpdatingDaemonManagement, toggle: handleToggleDaemonManagement } = const { isUpdating: isUpdatingDaemonManagement, toggle: handleToggleDaemonManagement } =
@@ -382,12 +401,12 @@ export function LocalDaemonSection() {
textStyle={settingsStyles.sectionHeaderLinkText} textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink} style={settingsStyles.sectionHeaderLink}
onPress={handleOpenAdvancedSettings} onPress={handleOpenAdvancedSettings}
accessibilityLabel="Open advanced daemon settings" accessibilityLabel={t("desktop.daemon.openAdvancedSettings")}
> >
Advanced settings {t("desktop.daemon.advancedSettings")}
</Button> </Button>
), ),
[advancedSettingsIcon, handleOpenAdvancedSettings], [advancedSettingsIcon, handleOpenAdvancedSettings, t],
); );
if (!showSection) { if (!showSection) {
@@ -396,7 +415,7 @@ export function LocalDaemonSection() {
return ( return (
<SettingsSection <SettingsSection
title="Daemon" title={t("desktop.daemon.title")}
trailing={advancedSettingsButton} trailing={advancedSettingsButton}
testID="host-page-daemon-lifecycle-card" testID="host-page-daemon-lifecycle-card"
> >
@@ -427,11 +446,7 @@ export function LocalDaemonSection() {
{daemonVersionMismatch ? ( {daemonVersionMismatch ? (
<View style={styles.warningCard}> <View style={styles.warningCard}>
<Text style={styles.warningText}> <Text style={styles.warningText}>{t("desktop.daemon.versionMismatch")}</Text>
{
"App and daemon versions don't match. Update both to the same version for the best experience."
}
</Text>
</View> </View>
) : null} ) : null}
</> </>
@@ -513,5 +528,3 @@ const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard];
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"]; const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"];
const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"]; const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"];
const DAEMON_LOGS_HEADER: SheetHeader = { title: "Daemon logs" };
const DAEMON_STATUS_HEADER: SheetHeader = { title: "Daemon status" };
@@ -1,4 +1,6 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; 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 { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { confirmDialog } from "@/utils/confirm-dialog"; 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"; import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; 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() { export function IntegrationsSection() {
const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon(); const showSection = shouldUseDesktopDaemon();
const { const {
@@ -56,20 +76,30 @@ export function IntegrationsSection() {
const handleUpdateSkills = useCallback(async () => { const handleUpdateSkills = useCallback(async () => {
if (isSkillsWorking) return; 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(); await updateSkills();
}, [isSkillsWorking, updateSkills]); }, [isSkillsWorking, skillsStatus, t, updateSkills]);
const handleUninstallSkills = useCallback(async () => { const handleUninstallSkills = useCallback(async () => {
if (isSkillsWorking) return; if (isSkillsWorking) return;
const confirmed = await confirmDialog({ const confirmed = await confirmDialog({
title: "Uninstall Paseo skills?", title: t("settings.integrations.skills.uninstallTitle"),
message: UNINSTALL_MESSAGE, message: t("settings.integrations.skills.uninstallMessage"),
confirmLabel: "Uninstall", confirmLabel: t("settings.integrations.actions.uninstall"),
destructive: true, destructive: true,
}); });
if (!confirmed) return; if (!confirmed) return;
await uninstallSkills(); await uninstallSkills();
}, [isSkillsWorking, uninstallSkills]); }, [isSkillsWorking, t, uninstallSkills]);
const handleOpenCliDocs = useCallback(() => { const handleOpenCliDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL); void openExternalUrl(CLI_DOCS_URL);
@@ -94,9 +124,9 @@ export function IntegrationsSection() {
textStyle={settingsStyles.sectionHeaderLinkText} textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink} style={settingsStyles.sectionHeaderLink}
onPress={handleOpenCliDocs} onPress={handleOpenCliDocs}
accessibilityLabel="Open CLI documentation" accessibilityLabel={t("settings.integrations.docs.openCli")}
> >
CLI docs {t("settings.integrations.docs.cli")}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -105,13 +135,13 @@ export function IntegrationsSection() {
textStyle={settingsStyles.sectionHeaderLinkText} textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink} style={settingsStyles.sectionHeaderLink}
onPress={handleOpenSkillsDocs} onPress={handleOpenSkillsDocs}
accessibilityLabel="Open skills documentation" accessibilityLabel={t("settings.integrations.docs.openSkills")}
> >
Skills docs {t("settings.integrations.docs.skills")}
</Button> </Button>
</View> </View>
), ),
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs], [arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs, t],
); );
if (!showSection) { if (!showSection) {
@@ -121,20 +151,24 @@ export function IntegrationsSection() {
const skillsState = skillsStatus?.state ?? null; const skillsState = skillsStatus?.state ?? null;
return ( return (
<SettingsSection title="Integrations" trailing={trailing}> <SettingsSection title={t("settings.integrations.title")} trailing={trailing}>
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
<View style={settingsStyles.row}> <View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}> <View style={styles.rowTitleRow}>
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} /> <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> </View>
<Text style={settingsStyles.rowHint}>Control and script agents from your terminal</Text> <Text style={settingsStyles.rowHint}>
{t("settings.integrations.commandLine.description")}
</Text>
</View> </View>
{cliStatus?.installed ? ( {cliStatus?.installed ? (
<View style={styles.installedLabel}> <View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} /> <Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text> <Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
</View> </View>
) : ( ) : (
<Button <Button
@@ -143,7 +177,9 @@ export function IntegrationsSection() {
onPress={handleInstallCli} onPress={handleInstallCli}
disabled={isInstallingCli} disabled={isInstallingCli}
> >
{isInstallingCli ? "Installing..." : "Install"} {isInstallingCli
? t("settings.integrations.actions.installing")
: t("settings.integrations.actions.install")}
</Button> </Button>
)} )}
</View> </View>
@@ -151,12 +187,12 @@ export function IntegrationsSection() {
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}> <View style={styles.rowTitleRow}>
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} /> <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> </View>
<Text style={settingsStyles.rowHint}> <Text style={settingsStyles.rowHint}>
{skillsState === "drift" {skillsState === "drift"
? "Update available" ? t("settings.integrations.skills.updateAvailable")
: "Teach your agents to orchestrate through the CLI"} : t("settings.integrations.skills.description")}
</Text> </Text>
</View> </View>
<SkillsActions <SkillsActions
@@ -181,6 +217,7 @@ interface SkillsActionsProps {
} }
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) { function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
if (state === "up-to-date") { if (state === "up-to-date") {
@@ -188,10 +225,10 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
<View style={styles.actionsRow}> <View style={styles.actionsRow}>
<View style={styles.installedLabel}> <View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} /> <Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text> <Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
</View> </View>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}> <Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
Uninstall {t("settings.integrations.actions.uninstall")}
</Button> </Button>
</View> </View>
); );
@@ -201,10 +238,12 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
return ( return (
<View style={styles.actionsRow}> <View style={styles.actionsRow}>
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}> <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>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}> <Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
Uninstall {t("settings.integrations.actions.uninstall")}
</Button> </Button>
</View> </View>
); );
@@ -212,7 +251,9 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
return ( return (
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}> <Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
{isWorking ? "Installing..." : "Install"} {isWorking
? t("settings.integrations.actions.installing")
: t("settings.integrations.actions.install")}
</Button> </Button>
); );
} }
@@ -1,3 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { PairDeviceSection } from "@/desktop/components/pair-device-section"; import { PairDeviceSection } from "@/desktop/components/pair-device-section";
@@ -8,12 +10,17 @@ export interface PairDeviceModalProps {
} }
const SNAP_POINTS: string[] = ["82%", "94%"]; const SNAP_POINTS: string[] = ["82%", "94%"];
const PAIR_DEVICE_HEADER: SheetHeader = { title: "Pair a device" };
export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) { export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) {
const { t } = useTranslation();
const header = useMemo<SheetHeader>(
() => ({ title: t("settings.integrations.pairDevices.rowTitle") }),
[t],
);
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
header={PAIR_DEVICE_HEADER} header={header}
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
snapPoints={SNAP_POINTS} snapPoints={SNAP_POINTS}
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Image, Text, TextInput, View } from "react-native"; import { ActivityIndicator, Image, Text, TextInput, View } from "react-native";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode"; import * as QRCode from "qrcode";
@@ -21,18 +22,21 @@ function resolvePairingViewState(args: {
isError: boolean; isError: boolean;
error: unknown; error: unknown;
data: { url?: string | null; relayEnabled?: boolean } | undefined; data: { url?: string | null; relayEnabled?: boolean } | undefined;
labels: {
failedToLoadOffer: string;
relayDisabled: string;
unavailable: string;
};
}): PairingViewState { }): PairingViewState {
if (args.isPending) return { tag: "loading" }; if (args.isPending) return { tag: "loading" };
if (args.isError) { if (args.isError) {
const message = 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 }; return { tag: "error", message };
} }
if (!args.data?.url) { if (!args.data?.url) {
const message = const message =
args.data?.relayEnabled === false args.data?.relayEnabled === false ? args.labels.relayDisabled : args.labels.unavailable;
? "Relay is not enabled. Enable relay to pair a device."
: "Pairing offer unavailable.";
return { tag: "unavailable", message }; return { tag: "unavailable", message };
} }
return { tag: "ready", url: args.data.url }; return { tag: "ready", url: args.data.url };
@@ -40,6 +44,7 @@ function resolvePairingViewState(args: {
export function PairDeviceSection() { export function PairDeviceSection() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation();
const showSection = shouldUseDesktopDaemon(); const showSection = shouldUseDesktopDaemon();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -96,6 +101,17 @@ export function PairDeviceSection() {
), ),
[copied, theme.iconSize.sm, theme.colors.accent, theme.colors.foreground], [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; if (!showSection) return null;
@@ -104,6 +120,11 @@ export function PairDeviceSection() {
isError: pairingQuery.isError, isError: pairingQuery.isError,
error: pairingQuery.error, error: pairingQuery.error,
data: pairingQuery.data, data: pairingQuery.data,
labels: {
failedToLoadOffer: t("pairing.device.failedToLoadOffer"),
relayDisabled: t("pairing.device.relayDisabled"),
unavailable: t("pairing.device.unavailable"),
},
}); });
return ( return (
@@ -119,6 +140,7 @@ export function PairDeviceSection() {
copied={copied} copied={copied}
handleRefetch={handleRefetch} handleRefetch={handleRefetch}
handleCopyPress={handleCopyPress} handleCopyPress={handleCopyPress}
labels={bodyLabels}
/> />
</View> </View>
</View> </View>
@@ -135,6 +157,14 @@ interface PairDeviceBodyProps {
copied: boolean; copied: boolean;
handleRefetch: () => void; handleRefetch: () => void;
handleCopyPress: () => void; handleCopyPress: () => void;
labels: {
loadingOffer: string;
hint: string;
qrUnavailable: string;
retry: string;
copy: string;
copied: string;
};
} }
function PairDeviceBody(props: PairDeviceBodyProps) { function PairDeviceBody(props: PairDeviceBodyProps) {
@@ -148,13 +178,14 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
copied, copied,
handleRefetch, handleRefetch,
handleCopyPress, handleCopyPress,
labels,
} = props; } = props;
if (viewState.tag === "loading") { if (viewState.tag === "loading") {
return ( return (
<View style={styles.centered}> <View style={styles.centered}>
<ActivityIndicator size="small" /> <ActivityIndicator size="small" />
<Text style={styles.hint}>Loading pairing offer…</Text> <Text style={styles.hint}>{labels.loadingOffer}</Text>
</View> </View>
); );
} }
@@ -164,7 +195,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
<View style={styles.centered}> <View style={styles.centered}>
<Text style={styles.hint}>{viewState.message}</Text> <Text style={styles.hint}>{viewState.message}</Text>
<Button variant="outline" size="sm" leftIcon={retryIcon} onPress={handleRefetch}> <Button variant="outline" size="sm" leftIcon={retryIcon} onPress={handleRefetch}>
Retry {labels.retry}
</Button> </Button>
</View> </View>
); );
@@ -172,11 +203,13 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
return ( return (
<View style={styles.content}> <View style={styles.content}>
<Text style={styles.hint}> <Text style={styles.hint}>{labels.hint}</Text>
Scan this QR code with Paseo on your phone, or copy the link below.
</Text>
<View style={styles.qrContainer}> <View style={styles.qrContainer}>
<PairDeviceQrContent qrImageSource={qrImageSource} qrQuery={qrQuery} /> <PairDeviceQrContent
qrImageSource={qrImageSource}
qrQuery={qrQuery}
unavailableLabel={labels.qrUnavailable}
/>
</View> </View>
<View style={styles.linkRow}> <View style={styles.linkRow}>
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
@@ -189,7 +222,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
/> />
</View> </View>
<Button variant="outline" size="sm" leftIcon={copyButtonIcon} onPress={handleCopyPress}> <Button variant="outline" size="sm" leftIcon={copyButtonIcon} onPress={handleCopyPress}>
{copied ? "Copied" : "Copy"} {copied ? labels.copied : labels.copy}
</Button> </Button>
</View> </View>
</View> </View>
@@ -199,12 +232,13 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
function PairDeviceQrContent(props: { function PairDeviceQrContent(props: {
qrImageSource: { uri: string } | null; qrImageSource: { uri: string } | null;
qrQuery: { isError: boolean }; qrQuery: { isError: boolean };
unavailableLabel: string;
}) { }) {
if (props.qrImageSource) { if (props.qrImageSource) {
return <Image source={props.qrImageSource} style={styles.qrImage} resizeMode="contain" />; return <Image source={props.qrImageSource} style={styles.qrImage} resizeMode="contain" />;
} }
if (props.qrQuery.isError) { 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" />; return <ActivityIndicator size="small" />;
} }
@@ -1,3 +1,5 @@
import { i18n } from "@/i18n/i18next";
export class DaemonConnectionRegistrationError extends Error { export class DaemonConnectionRegistrationError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
@@ -34,19 +36,18 @@ export function getDaemonManagementErrorPresentation(
if (presentationError instanceof DaemonConnectionRegistrationError) { if (presentationError instanceof DaemonConnectionRegistrationError) {
return { return {
message: message: i18n.t("desktop.daemon.management.registrationFailed"),
"Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.",
refreshStatus: true, refreshStatus: true,
}; };
} }
if (wasManagingDaemon) { if (wasManagingDaemon) {
return { return {
message: "Built-in daemon management was paused, but Paseo could not stop the daemon.", message: i18n.t("desktop.daemon.management.pausedStopFailed"),
refreshStatus: false, refreshStatus: false,
}; };
} }
return { return {
message: "Unable to update built-in daemon management.", message: i18n.t("desktop.daemon.management.updateFailed"),
refreshStatus: false, refreshStatus: false,
}; };
} }
@@ -1,5 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { import {
type DesktopDaemonStatus, type DesktopDaemonStatus,
startDesktopDaemon, startDesktopDaemon,
@@ -38,6 +39,7 @@ interface UseBuiltInDaemonManagementResult {
export function useBuiltInDaemonManagement( export function useBuiltInDaemonManagement(
input: UseBuiltInDaemonManagementInput, input: UseBuiltInDaemonManagementInput,
): UseBuiltInDaemonManagementResult { ): UseBuiltInDaemonManagementResult {
const { t } = useTranslation();
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input; const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
const reportError = useDesktopIpcErrorReporter(); const reportError = useDesktopIpcErrorReporter();
const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation< const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation<
@@ -50,11 +52,10 @@ export function useBuiltInDaemonManagement(
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, { const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
confirm: () => confirm: () =>
confirmDialog({ confirmDialog({
title: "Pause built-in daemon", title: t("desktop.daemon.management.pauseTitle"),
message: message: t("desktop.daemon.management.pauseMessage"),
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.", confirmLabel: t("desktop.daemon.management.pauseAndStop"),
confirmLabel: "Pause and stop", cancelLabel: t("common.actions.cancel"),
cancelLabel: "Cancel",
destructive: true, destructive: true,
}), }),
persistSettings: (next) => updateSettings(next) as Promise<void>, persistSettings: (next) => updateSettings(next) as Promise<void>,
@@ -1,5 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { import {
getDesktopDaemonLogs, getDesktopDaemonLogs,
getDesktopDaemonStatus, getDesktopDaemonStatus,
@@ -17,6 +18,7 @@ interface DaemonStatusData {
} }
export function useDaemonStatus() { export function useDaemonStatus() {
const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const enabled = shouldUseDesktopDaemon(); const enabled = shouldUseDesktopDaemon();
@@ -33,7 +35,7 @@ export function useDaemonStatus() {
}); });
useDesktopIpcQueryErrorToast({ useDesktopIpcQueryErrorToast({
error: query.error, error: query.error,
message: "Unable to load desktop daemon status.", message: t("desktop.daemon.loadFailed"),
logLabel: "[DesktopDaemon] Failed to load daemon status", logLabel: "[DesktopDaemon] Failed to load daemon status",
}); });
@@ -5,6 +5,7 @@ import React from "react";
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@/i18n/i18next";
import { useCliInstall, useSkillsStatus } from "./use-install-status"; import { useCliInstall, useSkillsStatus } from "./use-install-status";
const toast = vi.hoisted(() => ({ const toast = vi.hoisted(() => ({
@@ -54,6 +55,7 @@ describe("useCliInstall", () => {
}); });
afterEach(() => { afterEach(() => {
void i18n.changeLanguage("en");
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -89,6 +91,28 @@ describe("useCliInstall", () => {
expect(toast.error).toHaveBeenCalledWith("Unable to install the Paseo CLI."); expect(toast.error).toHaveBeenCalledWith("Unable to install the Paseo CLI.");
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install CLI", error); 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", () => { describe("useSkillsStatus", () => {
@@ -97,6 +121,7 @@ describe("useSkillsStatus", () => {
}); });
afterEach(() => { afterEach(() => {
void i18n.changeLanguage("en");
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -1,5 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { import {
getCliInstallStatus, getCliInstallStatus,
getSkillsStatus, getSkillsStatus,
@@ -29,6 +30,7 @@ interface DesktopInstallHookResult {
} }
export function useCliInstall(): DesktopInstallHookResult { export function useCliInstall(): DesktopInstallHookResult {
const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter(); const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon(); const enabled = shouldUseDesktopDaemon();
@@ -42,7 +44,7 @@ export function useCliInstall(): DesktopInstallHookResult {
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery; const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({ useDesktopIpcQueryErrorToast({
error: statusQuery.error, error: statusQuery.error,
message: "Unable to check CLI install status.", message: t("desktop.integrations.cli.statusFailed"),
logLabel: "[Integrations] Failed to load CLI status", logLabel: "[Integrations] Failed to load CLI status",
}); });
@@ -51,7 +53,7 @@ export function useCliInstall(): DesktopInstallHookResult {
onError: (error) => { onError: (error) => {
reportError({ reportError({
error, error,
message: "Unable to install the Paseo CLI.", message: t("desktop.integrations.cli.installFailed"),
logLabel: "[Integrations] Failed to install CLI", logLabel: "[Integrations] Failed to install CLI",
}); });
}, },
@@ -88,6 +90,7 @@ export interface SkillsStatusHookResult {
} }
export function useSkillsStatus(): SkillsStatusHookResult { export function useSkillsStatus(): SkillsStatusHookResult {
const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter(); const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon(); const enabled = shouldUseDesktopDaemon();
@@ -101,7 +104,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
const { data: status, error: statusError, isLoading, refetch } = statusQuery; const { data: status, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({ useDesktopIpcQueryErrorToast({
error: statusQuery.error, error: statusQuery.error,
message: "Unable to check orchestration skills status.", message: t("desktop.integrations.skills.statusFailed"),
logLabel: "[Integrations] Failed to load skills status", logLabel: "[Integrations] Failed to load skills status",
}); });
@@ -117,7 +120,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onError: (error) => { onError: (error) => {
reportError({ reportError({
error, error,
message: "Unable to install orchestration skills.", message: t("desktop.integrations.skills.installFailed"),
logLabel: "[Integrations] Failed to install skills", logLabel: "[Integrations] Failed to install skills",
}); });
}, },
@@ -129,7 +132,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onError: (error) => { onError: (error) => {
reportError({ reportError({
error, error,
message: "Unable to update orchestration skills.", message: t("desktop.integrations.skills.updateFailed"),
logLabel: "[Integrations] Failed to update skills", logLabel: "[Integrations] Failed to update skills",
}); });
}, },
@@ -141,7 +144,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onError: (error) => { onError: (error) => {
reportError({ reportError({
error, error,
message: "Unable to uninstall orchestration skills.", message: t("desktop.integrations.skills.uninstallFailed"),
logLabel: "[Integrations] Failed to uninstall skills", logLabel: "[Integrations] Failed to uninstall skills",
}); });
}, },
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { DesktopHostBridge } from "@/desktop/host"; import type { DesktopHostBridge } from "@/desktop/host";
import { i18n } from "@/i18n/i18next";
import { import {
createDesktopPermissions, createDesktopPermissions,
type DesktopPermissionEnvironment, type DesktopPermissionEnvironment,
@@ -180,4 +181,28 @@ describe("desktop-permissions", () => {
expect(result.state).toBe("denied"); 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");
}
});
}); });
@@ -1,5 +1,6 @@
import { type DesktopHostBridge, getDesktopHost } from "@/desktop/host"; import { type DesktopHostBridge, getDesktopHost } from "@/desktop/host";
import { isNative, isWeb } from "@/constants/platform"; import { isNative, isWeb } from "@/constants/platform";
import { i18n } from "@/i18n/i18next";
export type DesktopPermissionKind = "notifications" | "microphone"; export type DesktopPermissionKind = "notifications" | "microphone";
@@ -97,24 +98,26 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
if (permission === "granted") { if (permission === "granted") {
return status({ return status({
state: "granted", state: "granted",
detail: "Notifications are allowed by the OS.", detail: i18n.t("desktop.permissions.notifications.allowed"),
}); });
} }
if (permission === "denied") { if (permission === "denied") {
return status({ return status({
state: "denied", state: "denied",
detail: "Notifications are denied in system settings.", detail: i18n.t("desktop.permissions.notifications.denied"),
}); });
} }
if (permission === "default") { if (permission === "default") {
return status({ return status({
state: "prompt", state: "prompt",
detail: "Notifications have not been granted yet.", detail: i18n.t("desktop.permissions.notifications.notGranted"),
}); });
} }
return status({ return status({
state: "unknown", 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) { if (!env.isWeb) {
return status({ return status({
state: "unavailable", 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({ return status({
state: supported ? "granted" : "unavailable", state: supported ? "granted" : "unavailable",
detail: supported detail: supported
? "Desktop notifications are supported." ? i18n.t("desktop.permissions.notifications.supported")
: "Desktop notifications are not supported on this platform.", : i18n.t("desktop.permissions.notifications.unsupported"),
}); });
} catch { } catch {
// Fall through to web API check // Fall through to web API check
@@ -153,7 +156,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
return status({ return status({
state: "unavailable", 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) { if (!env.isWeb) {
return status({ return status({
state: "unavailable", 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) { if (!webNavigator) {
return status({ return status({
state: "unavailable", 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") { if (result?.state === "granted") {
return status({ return status({
state: "granted", state: "granted",
detail: "Microphone access is granted.", detail: i18n.t("desktop.permissions.microphone.granted"),
}); });
} }
if (result?.state === "denied") { if (result?.state === "denied") {
return status({ return status({
state: "denied", state: "denied",
detail: "Microphone access is denied in system settings.", detail: i18n.t("desktop.permissions.microphone.denied"),
}); });
} }
if (result?.state === "prompt") { if (result?.state === "prompt") {
return status({ return status({
state: "prompt", state: "prompt",
detail: "Microphone permission has not been granted yet.", detail: i18n.t("desktop.permissions.microphone.notGranted"),
}); });
} }
return status({ return status({
state: "unknown", state: "unknown",
detail: `Unexpected microphone permission state: ${result?.state ?? "unknown"}`, detail: i18n.t("desktop.permissions.microphone.unexpectedState", {
state: result?.state ?? "unknown",
}),
}); });
} catch (error) { } catch (error) {
if (isPermissionsQueryRuntimeUnsupported(error)) { if (isPermissionsQueryRuntimeUnsupported(error)) {
return status({ return status({
state: "unknown", state: "unknown",
detail: detail: i18n.t("desktop.permissions.microphone.statusApiUnavailable"),
"Microphone status API is unavailable in this runtime. Use Request to check access.",
}); });
} }
return status({ return status({
state: "unknown", 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") { if (typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
return status({ return status({
state: "unavailable", state: "unavailable",
detail: "Microphone capture is unavailable in this environment.", detail: i18n.t("desktop.permissions.microphone.captureUnavailable"),
}); });
} }
return status({ return status({
state: "unknown", 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) { if (!env.isWeb) {
return status({ return status({
state: "unavailable", 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) { } catch (error) {
return status({ return status({
state: "unknown", state: "unknown",
detail: `Failed to request notification permission: ${getErrorMessage(error)}`, detail: i18n.t("desktop.permissions.notifications.requestFailed", {
message: getErrorMessage(error),
}),
}); });
} }
} }
return status({ return status({
state: "unavailable", 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) { if (!env.isWeb) {
return status({ return status({
state: "unavailable", 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") { if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
return status({ return status({
state: "unavailable", 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") { if (errorName === "NotAllowedError" || errorName === "PermissionDeniedError") {
return status({ return status({
state: "denied", state: "denied",
detail: "Microphone permission was denied by the user or system.", detail: i18n.t("desktop.permissions.microphone.requestDenied"),
}); });
} }
if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") { if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") {
return status({ return status({
state: "unavailable", state: "unavailable",
detail: "No microphone device was found.", detail: i18n.t("desktop.permissions.microphone.noDevice"),
}); });
} }
return status({ return status({
state: "unknown", state: "unknown",
detail: `Failed to request microphone permission: ${getErrorMessage(error)}`, detail: i18n.t("desktop.permissions.microphone.requestFailed", {
message: getErrorMessage(error),
}),
}); });
} }
} }
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
getDesktopPermissionSnapshot, getDesktopPermissionSnapshot,
requestDesktopPermission, requestDesktopPermission,
@@ -20,17 +21,8 @@ export interface UseDesktopPermissionsReturn {
sendTestNotification: () => Promise<void>; 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 { export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const { t } = useTranslation();
const isDesktopApp = shouldShowDesktopPermissionSection(); const isDesktopApp = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null); const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
@@ -83,8 +75,14 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
setSnapshot((previous) => { setSnapshot((previous) => {
const base: DesktopPermissionSnapshot = previous ?? { const base: DesktopPermissionSnapshot = previous ?? {
checkedAt: Date.now(), checkedAt: Date.now(),
notifications: EMPTY_NOTIFICATION_STATUS, notifications: {
microphone: EMPTY_MICROPHONE_STATUS, state: "unknown",
detail: t("desktop.permissions.empty.notifications"),
},
microphone: {
state: "unknown",
detail: t("desktop.permissions.empty.microphone"),
},
}; };
if (kind === "notifications") { if (kind === "notifications") {
@@ -110,7 +108,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
await refreshPermissions(); await refreshPermissions();
} }
}, },
[isDesktopApp, refreshPermissions], [isDesktopApp, refreshPermissions, t],
); );
const [testNotificationError, setTestNotificationError] = useState<string | null>(null); const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
@@ -124,22 +122,20 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
setTestNotificationError(null); setTestNotificationError(null);
try { try {
const sent = await sendOsNotification({ const sent = await sendOsNotification({
title: "Paseo notification test", title: t("desktop.permissions.testNotification.title"),
body: "If you can see this, desktop notifications work.", body: t("desktop.permissions.testNotification.body"),
}); });
if (!sent) { if (!sent) {
setTestNotificationError( setTestNotificationError(t("desktop.permissions.testNotification.notDelivered"));
"Notification was not delivered. Check System Settings > Notifications.",
);
} }
} catch { } catch {
setTestNotificationError("Failed to send notification."); setTestNotificationError(t("desktop.permissions.testNotification.failed"));
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) {
setIsSendingTestNotification(false); setIsSendingTestNotification(false);
} }
} }
}, [isDesktopApp]); }, [isDesktopApp, t]);
useEffect(() => { useEffect(() => {
if (!isDesktopApp) { if (!isDesktopApp) {

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