mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 12:23:16 +00:00
Complete client UI i18n migration
Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
committed by
GitHub
parent
41cf070fd8
commit
08ebe5e7e1
79
docs/i18n.md
Normal file
79
docs/i18n.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# I18n
|
||||
|
||||
Paseo client UI translations live in `packages/app/src/i18n`.
|
||||
|
||||
## Supported Locales
|
||||
|
||||
- `en`
|
||||
- `ar`
|
||||
- `es`
|
||||
- `fr`
|
||||
- `ru`
|
||||
- `zh-CN`
|
||||
|
||||
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English.
|
||||
|
||||
## Translation Scope
|
||||
|
||||
Translate client-owned UI copy: labels, buttons, empty states, confirmation text, and local status/error wrappers.
|
||||
|
||||
Do not translate agent output, daemon output, terminal contents, file paths, provider names, model names, command names, user-authored text, code blocks, logs, or raw protocol/server error text.
|
||||
|
||||
## Adding Copy
|
||||
|
||||
English source strings live in `packages/app/src/i18n/resources/en.ts`. Simplified Chinese strings live in `packages/app/src/i18n/resources/zh-CN.ts`.
|
||||
|
||||
For migrated screens and components, use `useTranslation()` and pass translated text into UI primitives. Low-level primitives such as `<Button>` do not import translation state unless they own the text they render.
|
||||
|
||||
Keep resource keys grouped by product surface, not component mechanics.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
|
||||
```
|
||||
|
||||
The parity test catches missing keys between English and Simplified Chinese resources.
|
||||
|
||||
## Migration Order
|
||||
|
||||
Client UI translation is staged so each pass can migrate complete local copy clusters and keep reviews focused.
|
||||
|
||||
1. App shell and shared UI chrome: common actions, headers, sheets, command center, and client-owned toast status.
|
||||
2. Composer and agent workflow: composer input, agent controls, permission prompts, plan approval, and agent panel wrapper states.
|
||||
3. Settings expansion: Appearance, Shortcuts, Integrations, Permissions, Diagnostics, About, Project settings, Host settings, and provider diagnostics.
|
||||
4. Workspace and panels: setup/file/browser/terminal wrapper copy, file explorer local states, import-session flows, and remaining local toast/error wrapper text.
|
||||
|
||||
Within a migrated surface, do not leave mixed-language neighboring labels when those labels are owned by the client. Move the whole local copy cluster together.
|
||||
|
||||
### Progress
|
||||
|
||||
- Batch 2 migrated Composer and agent workflow chrome: Composer input and attachments, agent controls, stream permission prompts, agent panel wrapper states, and draft panel descriptors. Provider/model names, provider-defined option labels, agent output, and protocol/server diagnostics remain untranslated.
|
||||
- Batch 3A migrated Settings Diagnostics/About, Appearance, Shortcuts, Integrations, and Desktop Permissions chrome. Host settings and Project settings remain for Batch 3B; raw runtime status/error details remain untranslated.
|
||||
- Batch 3B migrated Host settings, Provider diagnostics, and Project settings chrome. Provider/model names, project/host labels, script commands, diagnostic output, file paths, and raw runtime/server error details remain untranslated.
|
||||
- Batch 4A migrated workspace wrapper chrome for import sessions, file explorer, setup, browser, terminal, and file panels. File paths, URLs, commands, logs, terminal output, provider labels, and raw runtime/server errors remain untranslated.
|
||||
- Batch 4B migrated workspace tab shell, workspace scripts, Git actions/diff/PR chrome, worktree archive warnings, Open-in-editor controls, and inline review controls. Branch names, PR titles/bodies, check names, workflow names, file paths, diff contents, commands, terminal output, provider labels, and raw runtime/server errors remain untranslated.
|
||||
- Batch 4C migrated Sidebar project/workspace menus, hide/remove/archive confirmations, workspace rename chrome, New workspace ref picker/create flow, and Open project home tiles. Workspace/project names, branch names, PR titles, paths, provider labels, daemon output, and raw runtime/server errors remain untranslated.
|
||||
- Batch 4D migrated provider/model selector chrome, provider catalog install modal, add-connection method modal, and paste-pairing-link modal. Provider/model/catalog names, provider descriptions, pairing URLs, protocol parser errors, daemon connection details, and raw runtime/server errors remain untranslated.
|
||||
- Batch 4E migrated onboarding welcome chrome, direct-connection modal fields/actions/local failure guidance, QR scan permission/unavailable states, and desktop pair-device card. Pairing URLs, host/port placeholders, endpoint values, transport details, protocol parser errors, and raw runtime/server errors remain untranslated.
|
||||
- Batch 4F migrated realtime voice overlay accessibility labels, rewind menu chrome and fallback toast, DiffViewer default empty state, and service URL chooser copy. Shortcut key names, raw daemon errors, diff contents, URLs, and caller-provided override labels remain untranslated.
|
||||
- Batch 4G migrated the keyboard shortcuts help dialog to render section titles, row labels, and row notes through translation keys. Shortcut key names, shortcut combos, binding IDs, action IDs, and fallback registry labels remain untranslated.
|
||||
- Batch 4H migrated Sessions screen chrome and AgentList local UI copy: date section headers, local status labels, fallback session titles, badges, load-more/empty states, and the archive action sheet. Agent titles, project paths, provider icons/labels, host labels, relative timestamps, and raw runtime data remain untranslated.
|
||||
- Batch 4I migrated message utility chrome: image lightbox labels/errors, code and turn copy accessibility labels, dictation controls, question form fallback placeholders/actions, PlanCard fallback title, assistant image fallback errors, and todo list labels. Message bodies, plan text, todo item text, attachment labels, runtime dictation errors, and agent/tool output remain untranslated.
|
||||
- Batch 4J migrated workspace tab toast/empty chrome: copy failure messages, copied labels, resume-command availability errors, reload-agent local status, host-disconnected wrapper reuse, and split-pane empty state. Agent IDs, generated resume commands, workspace paths, branch names, and raw reload errors remain untranslated.
|
||||
- Batch 4K migrated sidebar/project list chrome: host picker fallback/search/title, footer actions and tooltips, Sessions row labels, mobile close label, New workspace tooltip/accessibility label, project-list empty states, and project settings host-load wrapper text. Host names, project names, workspace names, paths, branch names, and raw host errors remain untranslated.
|
||||
- Batch 4L migrated picker/file/detail utility chrome: project picker states, branch switcher labels/placeholders, file pane loading/empty/fallback errors, tool-call details section/empty labels, and open-file accessibility. Directory paths, branch names, file contents, file sizes, tool details, and raw file-load errors remain untranslated.
|
||||
- Batch 4M migrated hook/modal utility chrome: image attachment permissions/dialog/errors, copied toast wrappers, rename modal local validation/fallback errors, branch switcher stash/switch prompts and fallback toasts, and workspace setup local fallback errors. Copied labels supplied by callers, branch names, selected paths, raw dialog/API errors, and raw server errors remain untranslated.
|
||||
- Batch 4N migrated pure view-model/policy utility chrome: import-session fallback titles/previews/empty states and the worktree setup callout. Provider labels and IDs remain runtime values interpolated into translated wrappers.
|
||||
- Batch 4O migrated remaining small utility chrome: workspace route gate states/actions, compaction markers, archived-agent callout, web browser fallback, desktop quitting overlay, and image drop overlay. Host names, host status values, browser IDs, token counts, and raw route errors remain runtime values.
|
||||
- Batch 4P migrated provider-selection pure view-model utility copy to direct `i18n.t(...)` calls and removed the local labels parameter path. Provider/model labels, provider IDs, and provider snapshot error messages remain runtime values.
|
||||
- Batch 4Q migrated desktop update utility chrome: app update status text, update callout titles/actions, generic update errors, and install-error wrappers. Version labels, installer messages, raw update errors, release-channel data, and logs remain runtime values.
|
||||
- Batch 4R migrated desktop permission utility chrome: permission status details, permission request fallback errors, empty permission statuses, and desktop notification test wrappers. Browser permission states, exception names, and raw browser API error messages remain runtime values.
|
||||
- Batch 4S migrated desktop daemon settings chrome: built-in daemon status rows, daemon lifecycle toggles, logs/status modals, clipboard alerts, daemon management confirmations/errors, daemon status load errors, and desktop CLI/skills install wrapper errors. PIDs, log paths, log contents, CLI status output, version values, and raw IPC errors remain runtime values.
|
||||
- Batch 4T migrated remaining attachment/autocomplete utility chrome: user and composer review attachment labels, workspace hover-card accessibility, branch stash restore prompts/toasts, agent autocomplete loading/empty/fallback error text, older-history fallback toast, draft panel labels, and agent-control fallback labels. PR/issue numbers, browser element tags, branch names, provider labels, model labels, agent prompts, command/file names, and raw server errors remain runtime values.
|
||||
- Batch 4U migrated shared default utility chrome: Combobox and Autocomplete default placeholders/empty/loading labels, drag-overlay and subagent-track loading labels, sub-agent activity fallback headers, and file-preview fallback errors. Caller-provided labels, tab titles, subagent descriptions, file paths, and raw file-load errors remain runtime values.
|
||||
- Batch 4V migrated Git policy action chrome to direct `i18n.t(...)` calls: commit/pull/push/sync/PR/merge/auto-merge/archive action labels, pending/success labels, and unavailable reasons. Branch/base refs, PR URLs, GitHub merge-state enum values, runtime statuses, and raw Git/GitHub errors remain runtime values.
|
||||
- Batch 4W migrated remaining local wrapper states: workspace copy unavailable toasts, startup daemon-log loading/empty/load-failed text, and file-explorer workspace/host unavailable fallbacks. Workspace paths, branch names, daemon log contents/paths, checkout query details, and raw file/daemon errors remain runtime values.
|
||||
- Batch 4X migrated descriptor and command chrome: Pair-device modal header, workspace setup sheet title, terminal panel fallback labels, command-center action titles, and file-pane host-disconnected fallback. Provider/catalog names, command-center search keywords, terminal runtime titles, file paths, and raw read errors remain runtime values.
|
||||
- Batch 4Y tightened the translation boundary so React components and custom hooks use `useTranslation()` while pure helpers keep direct `i18n.t(...)` fallbacks, and migrated remaining small UI/accessibility fallbacks across message details, menu backdrops, startup errors, sidebar PR badges, settings/project accessibility labels, composer send/create/download fallbacks, client slash-command descriptions, terminal subscribe errors, and desktop update completion text. Provider catalog metadata, shortcut registry fallbacks, agent/daemon/protocol reasons, terminal contents, raw runtime errors, and user/project/workspace names remain untranslated.
|
||||
- Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch.
|
||||
103
package-lock.json
generated
103
package-lock.json
generated
@@ -2567,9 +2567,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -20529,6 +20529,19 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-localization": {
|
||||
"version": "17.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.9.tgz",
|
||||
"integrity": "sha512-k5eYHr7iLMob3M8cHH6bgDrDRmqnZG8xKGLhpx8ST+LsFXmSgYpJ/t0VwWm0gz64C/K669XhObdG3D6QwCGqhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rtl-detect": "^1.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-manifests": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz",
|
||||
@@ -23838,6 +23851,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -24016,6 +24038,34 @@
|
||||
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
|
||||
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-corefoundation": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
|
||||
@@ -31270,6 +31320,33 @@
|
||||
"react": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||
@@ -32650,6 +32727,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/rtl-detect": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
|
||||
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -35233,7 +35316,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -36118,6 +36201,15 @@
|
||||
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
@@ -37026,6 +37118,7 @@
|
||||
"expo-image-picker": "^17.0.8",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-localization": "~17.0.9",
|
||||
"expo-notifications": "^0.32.16",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-sharing": "^14.0.8",
|
||||
@@ -37033,12 +37126,14 @@
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function editWorktreeSetup(page: Page, setupCommands: string[]): Pr
|
||||
}
|
||||
|
||||
export async function clickSaveProjectSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save project config" }).click();
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
}
|
||||
|
||||
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
|
||||
@@ -79,7 +79,7 @@ export async function expectWriteFailedCalloutActions(page: Page): Promise<void>
|
||||
}
|
||||
|
||||
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Save project config" })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
}
|
||||
|
||||
// --- Form-state assertions ---
|
||||
|
||||
23
packages/app/e2e/settings-i18n.spec.ts
Normal file
23
packages/app/e2e/settings-i18n.spec.ts
Normal 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();
|
||||
});
|
||||
@@ -73,6 +73,7 @@
|
||||
"expo-image-picker": "^17.0.8",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-localization": "~17.0.9",
|
||||
"expo-notifications": "^0.32.16",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-sharing": "^14.0.8",
|
||||
@@ -80,12 +81,14 @@
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, {
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -157,6 +158,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
function renderListEmptyComponent(input: {
|
||||
renderModel: AgentStreamRenderModel;
|
||||
emptyStateStyle: StyleProp<ViewStyle>;
|
||||
emptyText: string;
|
||||
}): ReactNode {
|
||||
if (
|
||||
input.renderModel.boundary.hasVirtualizedHistory ||
|
||||
@@ -170,7 +172,7 @@ function renderListEmptyComponent(input: {
|
||||
|
||||
return (
|
||||
<View style={input.emptyStateStyle}>
|
||||
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
|
||||
<Text style={stylesheet.emptyStateText}>{input.emptyText}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -245,6 +247,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const streamRenderStrategy = useMemo(
|
||||
@@ -637,8 +640,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
|
||||
const listEmptyComponent = useMemo(
|
||||
() => renderListEmptyComponent({ renderModel, emptyStateStyle }),
|
||||
[renderModel, emptyStateStyle],
|
||||
() =>
|
||||
renderListEmptyComponent({
|
||||
renderModel,
|
||||
emptyStateStyle,
|
||||
emptyText: t("agentStream.empty"),
|
||||
}),
|
||||
[renderModel, emptyStateStyle, t],
|
||||
);
|
||||
|
||||
const { boundary, auxiliary } = renderModel;
|
||||
@@ -745,7 +753,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Scroll to bottom"
|
||||
accessibilityLabel={t("agentStream.scrollToBottom")}
|
||||
testID="scroll-to-bottom-button"
|
||||
>
|
||||
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
|
||||
@@ -906,11 +914,14 @@ function PermissionRequestCard({
|
||||
permission: PendingPermission;
|
||||
client: DaemonClient | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const { request } = permission;
|
||||
const isPlanRequest = request.kind === "plan";
|
||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||
const title = isPlanRequest
|
||||
? t("agentStream.permission.plan")
|
||||
: (request.title ?? request.name ?? t("agentStream.permission.required"));
|
||||
const description = request.description ?? "";
|
||||
const resolvedToolCallDetail = useMemo(
|
||||
() =>
|
||||
@@ -931,19 +942,21 @@ function PermissionRequestCard({
|
||||
return [
|
||||
{
|
||||
id: "reject",
|
||||
label: "Deny",
|
||||
label: t("agentStream.permission.deny"),
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "accept",
|
||||
label: isPlanRequest ? "Implement" : "Accept",
|
||||
label: isPlanRequest
|
||||
? t("agentStream.permission.implement")
|
||||
: t("agentStream.permission.accept"),
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
},
|
||||
];
|
||||
}, [isPlanRequest, request]);
|
||||
}, [isPlanRequest, request, t]);
|
||||
|
||||
const planMarkdown = useMemo(() => {
|
||||
if (!request) {
|
||||
@@ -968,7 +981,7 @@ function PermissionRequestCard({
|
||||
response: AgentPermissionResponse;
|
||||
}) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
return client.respondToPermissionAndWait(
|
||||
input.agentId,
|
||||
@@ -1042,7 +1055,7 @@ function PermissionRequestCard({
|
||||
const footer = (
|
||||
<>
|
||||
<Text testID="permission-request-question" style={permissionStyles.question}>
|
||||
How would you like to proceed?
|
||||
{t("agentStream.permission.question")}
|
||||
</Text>
|
||||
|
||||
<View style={optionsContainerStyle}>
|
||||
@@ -1094,7 +1107,7 @@ function PermissionRequestCard({
|
||||
|
||||
{planMarkdown ? (
|
||||
<PlanCard
|
||||
title="Proposed plan"
|
||||
title={t("agentStream.permission.proposedPlan")}
|
||||
text={planMarkdown}
|
||||
testID="permission-plan-card"
|
||||
disableOuterSpacing
|
||||
|
||||
@@ -69,6 +69,7 @@ import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { I18nProvider } from "@/i18n/provider";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
@@ -934,13 +935,15 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
function RootProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
<I18nProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</I18nProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -120,6 +121,7 @@ function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
@@ -190,13 +192,13 @@ export default function PairScanScreen() {
|
||||
navigateToPairedHost(profile.serverId);
|
||||
} catch (error) {
|
||||
lastScannedRef.current = null;
|
||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||
Alert.alert("Error", message);
|
||||
const message = error instanceof Error ? error.message : t("pairing.scan.unableToPair");
|
||||
Alert.alert(t("pairing.scan.errorTitle"), message);
|
||||
} finally {
|
||||
setIsPairing(false);
|
||||
}
|
||||
},
|
||||
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
|
||||
[isPairing, navigateToPairedHost, t, upsertDaemonFromOfferUrl],
|
||||
);
|
||||
|
||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||
@@ -216,15 +218,13 @@ export default function PairScanScreen() {
|
||||
if (isWeb) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Scan QR" onBack={handleRouterBack} />
|
||||
<BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
|
||||
<View style={bodyStyle}>
|
||||
<View style={styles.permissionCard}>
|
||||
<Text style={styles.permissionTitle}>Not available on web</Text>
|
||||
<Text style={styles.permissionBody}>
|
||||
{`QR scanning isn't supported in the web build. Use "Paste link" instead.`}
|
||||
</Text>
|
||||
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
|
||||
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
|
||||
<Pressable style={styles.permissionButton} onPress={closeToSource}>
|
||||
<Text style={styles.permissionButtonText}>Back to Settings</Text>
|
||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -236,17 +236,15 @@ export default function PairScanScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Scan QR" onBack={closeToSource} />
|
||||
<BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
|
||||
|
||||
<View style={bodyStyle}>
|
||||
{!granted ? (
|
||||
<View style={styles.permissionCard}>
|
||||
<Text style={styles.permissionTitle}>Camera permission</Text>
|
||||
<Text style={styles.permissionBody}>
|
||||
Allow camera access to scan the pairing QR code from your daemon.
|
||||
</Text>
|
||||
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
|
||||
<Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</Text>
|
||||
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
|
||||
<Text style={styles.permissionButtonText}>Grant permission</Text>
|
||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
@@ -264,7 +262,7 @@ export default function PairScanScreen() {
|
||||
<View style={CORNER_BL_STYLE} />
|
||||
<View style={CORNER_BR_STYLE} />
|
||||
</View>
|
||||
{isPairing ? <Text style={helperTextStyle}>Pairing…</Text> : null}
|
||||
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AssistantFileLinkClassification,
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export interface AssistantFileLinkSource {
|
||||
href: string;
|
||||
@@ -60,7 +61,7 @@ export interface FetchDaemonResolutionInput {
|
||||
|
||||
export class UnresolvedFileLinkError extends Error {
|
||||
constructor(readonly token: string) {
|
||||
super(`No file found for ${token}`);
|
||||
super(i18n.t("common.errors.noFileFound", { token }));
|
||||
this.name = "UnresolvedFileLinkError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
UnresolvedFileLinkError,
|
||||
type AssistantFileLinkResolution,
|
||||
type AssistantFileLinkSource,
|
||||
} from "./resolver";
|
||||
@@ -40,6 +40,7 @@ type AssistantFileLinkQueryKey = readonly [
|
||||
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
|
||||
|
||||
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
|
||||
const { t } = useTranslation();
|
||||
const context = useAssistantFileLinkResolverContext();
|
||||
const queryClient = useQueryClient();
|
||||
const stableSource = useStableSource(source);
|
||||
@@ -91,6 +92,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
||||
disposition,
|
||||
context,
|
||||
queryClient,
|
||||
formatNoFileFoundMessage: (token) => t("common.errors.noFileFound", { token }),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -163,6 +165,7 @@ function openAssistantFileLink(input: {
|
||||
disposition: OpenFileDisposition;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
queryClient: ReturnType<typeof useQueryClient>;
|
||||
formatNoFileFoundMessage: (token: string) => string;
|
||||
}): void {
|
||||
const capturedConfig = input.context.configRef.current;
|
||||
const capturedResolution = classifyForResolution(input.source, {
|
||||
@@ -211,7 +214,7 @@ function openAssistantFileLink(input: {
|
||||
} catch (error) {
|
||||
await dispatchUnresolvedError({
|
||||
error,
|
||||
fallbackToken: capturedResolution.token,
|
||||
noFileFoundMessage: input.formatNoFileFoundMessage(capturedResolution.token),
|
||||
capturedServerId: capturedConfig.serverId,
|
||||
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
|
||||
context: input.context,
|
||||
@@ -322,7 +325,7 @@ async function dispatchExternalUrl(input: {
|
||||
|
||||
async function dispatchUnresolvedError(input: {
|
||||
error: unknown;
|
||||
fallbackToken: string;
|
||||
noFileFoundMessage: string;
|
||||
capturedServerId?: string;
|
||||
capturedWorkspaceRoot?: string;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
@@ -334,9 +337,7 @@ async function dispatchUnresolvedError(input: {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const token =
|
||||
input.error instanceof UnresolvedFileLinkError ? input.error.token : input.fallbackToken;
|
||||
current.toast?.show(`No file found for ${token}`, {
|
||||
current.toast?.show(input.noFileFoundMessage, {
|
||||
variant: "error",
|
||||
testID: "assistant-file-link-not-found-toast",
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ClientSlashCommand {
|
||||
name: string;
|
||||
aliases: readonly string[];
|
||||
description: string;
|
||||
descriptionKey: "composer.clientCommands.archiveAgent" | "composer.clientCommands.freshDraft";
|
||||
argumentHint: string;
|
||||
kind: ClientSlashCommandKind;
|
||||
execution: ClientSlashCommandExecution;
|
||||
@@ -18,6 +19,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
|
||||
name: "exit",
|
||||
aliases: ["quit", "q"],
|
||||
description: "Archive the current agent",
|
||||
descriptionKey: "composer.clientCommands.archiveAgent",
|
||||
argumentHint: "",
|
||||
kind: "archive-agent",
|
||||
execution: "immediate",
|
||||
@@ -26,6 +28,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
|
||||
name: "clear",
|
||||
aliases: ["new"],
|
||||
description: "Archive this agent and start a fresh draft",
|
||||
descriptionKey: "composer.clientCommands.freshDraft",
|
||||
argumentHint: "",
|
||||
kind: "replace-agent-with-draft",
|
||||
execution: "immediate",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo } from "react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import type { TextInputProps } from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
@@ -304,6 +305,7 @@ export function SheetHeaderView({
|
||||
testID?: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
@@ -327,7 +329,7 @@ export function SheetHeaderView({
|
||||
hitSlop={8}
|
||||
style={styles.headerBackButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
|
||||
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")}
|
||||
testID="sheet-header-back"
|
||||
>
|
||||
{({ pressed }) => (
|
||||
@@ -347,7 +349,11 @@ export function SheetHeaderView({
|
||||
</View>
|
||||
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
|
||||
{showCloseButton ? (
|
||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||
<Pressable
|
||||
accessibilityLabel={t("common.actions.close")}
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
{({ pressed }) => (
|
||||
<X
|
||||
size={16}
|
||||
@@ -363,7 +369,7 @@ export function SheetHeaderView({
|
||||
<AdaptiveTextInput
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={SEARCH_INPUT_STYLE}
|
||||
placeholder={search.placeholder ?? "Search"}
|
||||
placeholder={search.placeholder ?? t("common.actions.search")}
|
||||
resetKey={search.resetKey}
|
||||
onChangeText={handleSearchChange}
|
||||
autoCapitalize="none"
|
||||
@@ -379,6 +385,7 @@ export function SheetHeaderView({
|
||||
|
||||
export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const back = header.back;
|
||||
const handleBackPress = back?.onPress;
|
||||
const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions);
|
||||
@@ -393,7 +400,9 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
hitSlop={8}
|
||||
style={styles.headerBackButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
|
||||
accessibilityLabel={
|
||||
back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")
|
||||
}
|
||||
testID="sheet-header-back"
|
||||
>
|
||||
{({ pressed }) => (
|
||||
@@ -417,7 +426,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
<AdaptiveTextInput
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={SEARCH_INPUT_STYLE}
|
||||
placeholder={header.search.placeholder ?? "Search"}
|
||||
placeholder={header.search.placeholder ?? t("common.actions.search")}
|
||||
resetKey={header.search.resetKey}
|
||||
onChangeText={header.search.onChange}
|
||||
autoCapitalize="none"
|
||||
@@ -462,6 +471,7 @@ export function AdaptiveModalSheet({
|
||||
presentation,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
@@ -585,7 +595,11 @@ export function AdaptiveModalSheet({
|
||||
|
||||
const desktopContent = (
|
||||
<View style={styles.desktopOverlay} testID={testID}>
|
||||
<Pressable accessibilityLabel="Dismiss" style={ABSOLUTE_FILL_STYLE} onPress={onClose} />
|
||||
<Pressable
|
||||
accessibilityLabel={t("common.actions.dismiss")}
|
||||
style={ABSOLUTE_FILL_STYLE}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View style={desktopCardStyle}>
|
||||
{onFilesDropped ? (
|
||||
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
const ADD_CONNECTION_HEADER: SheetHeader = { title: "Add connection" };
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
option: {
|
||||
flexDirection: "row",
|
||||
@@ -49,6 +48,8 @@ export function AddHostMethodModal({
|
||||
onPasteLink,
|
||||
}: AddHostMethodModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.connectionMethods.title") }), [t]);
|
||||
|
||||
const handleDirect = useCallback(() => {
|
||||
onDirectConnection();
|
||||
@@ -64,7 +65,7 @@ export function AddHostMethodModal({
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={ADD_CONNECTION_HEADER}
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
testID="add-host-method-modal"
|
||||
@@ -73,13 +74,15 @@ export function AddHostMethodModal({
|
||||
style={styles.option}
|
||||
onPress={handleDirect}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Direct connection"
|
||||
accessibilityLabel={t("pairing.connectionMethods.direct.title")}
|
||||
testID="add-host-method-direct"
|
||||
>
|
||||
<Link2 size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Direct connection</Text>
|
||||
<Text style={styles.optionSubtext}>Local network or VPN.</Text>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.direct.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
{t("pairing.connectionMethods.direct.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
@@ -88,12 +91,14 @@ export function AddHostMethodModal({
|
||||
style={styles.option}
|
||||
onPress={handleScan}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Scan QR code"
|
||||
accessibilityLabel={t("pairing.connectionMethods.scanQr.title")}
|
||||
>
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Scan QR code</Text>
|
||||
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.scanQr.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
{t("pairing.connectionMethods.scanQr.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
@@ -102,13 +107,15 @@ export function AddHostMethodModal({
|
||||
style={styles.option}
|
||||
onPress={handlePaste}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Paste pairing link"
|
||||
accessibilityLabel={t("pairing.connectionMethods.pasteLink.title")}
|
||||
testID="add-host-method-pair-link"
|
||||
>
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Paste pairing link</Text>
|
||||
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.pasteLink.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
{t("pairing.connectionMethods.pasteLink.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useReducer, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -15,7 +16,6 @@ import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adapt
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const DIRECT_CONNECTION_HEADER: SheetHeader = { title: "Direct connection" };
|
||||
|
||||
interface DirectConnectionDraft {
|
||||
host: string;
|
||||
@@ -31,6 +31,20 @@ interface PreparedDirectConnection {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface DirectConnectionLabels {
|
||||
hostRequired: string;
|
||||
invalidPort: string;
|
||||
invalidConnection: string;
|
||||
failedToConnect: (endpoint: string) => string;
|
||||
noAdditionalDetails: (detail: string) => string;
|
||||
timedOut: string;
|
||||
refused: string;
|
||||
hostNotFound: string;
|
||||
hostUnreachable: string;
|
||||
tlsError: string;
|
||||
unableToConnect: string;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
field: {
|
||||
gap: theme.spacing[2],
|
||||
@@ -128,14 +142,17 @@ function isIpv6Host(host: string): boolean {
|
||||
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
|
||||
}
|
||||
|
||||
function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string {
|
||||
function buildConnectionUriFromDraft(
|
||||
draft: DirectConnectionDraft,
|
||||
labels: DirectConnectionLabels,
|
||||
): string {
|
||||
const host = draft.host.trim();
|
||||
const port = Number(draft.port.trim());
|
||||
if (!host) {
|
||||
throw new Error("Host is required");
|
||||
throw new Error(labels.hostRequired);
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
throw new Error(labels.invalidPort);
|
||||
}
|
||||
|
||||
return serializeConnectionUriForStorage({
|
||||
@@ -147,8 +164,11 @@ function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string {
|
||||
});
|
||||
}
|
||||
|
||||
function prepareDirectConnection(draft: DirectConnectionDraft): PreparedDirectConnection {
|
||||
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft));
|
||||
function prepareDirectConnection(
|
||||
draft: DirectConnectionDraft,
|
||||
labels: DirectConnectionLabels,
|
||||
): PreparedDirectConnection {
|
||||
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels));
|
||||
const endpoint = parsed.isIpv6
|
||||
? `[${parsed.host}]:${parsed.port}`
|
||||
: `${parsed.host}:${parsed.port}`;
|
||||
@@ -178,7 +198,10 @@ function normalizeTransportMessage(message: string | null | undefined): string |
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function formatTechnicalTransportDetails(details: (string | null)[]): string | null {
|
||||
function formatTechnicalTransportDetails(
|
||||
details: (string | null)[],
|
||||
labels: DirectConnectionLabels,
|
||||
): string | null {
|
||||
const unique = Array.from(
|
||||
new Set(
|
||||
details
|
||||
@@ -197,22 +220,24 @@ function formatTechnicalTransportDetails(details: (string | null)[]): string | n
|
||||
});
|
||||
|
||||
if (allGeneric) {
|
||||
return `${unique[0]} (no additional details provided)`;
|
||||
return labels.noAdditionalDetails(unique[0] ?? "");
|
||||
}
|
||||
|
||||
return unique.join(" — ");
|
||||
}
|
||||
|
||||
function buildConnectionFailureCopy(
|
||||
endpoint: string,
|
||||
error: unknown,
|
||||
): { title: string; detail: string | null; raw: string | null } {
|
||||
const title = `We failed to connect to ${endpoint}.`;
|
||||
function buildConnectionFailureCopy(input: {
|
||||
endpoint: string;
|
||||
error: unknown;
|
||||
labels: DirectConnectionLabels;
|
||||
}): { title: string; detail: string | null; raw: string | null } {
|
||||
const { endpoint, error, labels } = input;
|
||||
const title = labels.failedToConnect(endpoint);
|
||||
|
||||
const raw = (() => {
|
||||
if (error instanceof DaemonConnectionTestError) {
|
||||
return (
|
||||
formatTechnicalTransportDetails([error.reason, error.lastError]) ??
|
||||
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
|
||||
normalizeTransportMessage(error.message)
|
||||
);
|
||||
}
|
||||
@@ -228,26 +253,25 @@ function buildConnectionFailureCopy(
|
||||
if (raw === "Incorrect password" || raw === "Password required") {
|
||||
detail = raw;
|
||||
} else if (rawLower.includes("timed out")) {
|
||||
detail = "Connection timed out. Check the host/port and your network.";
|
||||
detail = labels.timedOut;
|
||||
} else if (
|
||||
rawLower.includes("econnrefused") ||
|
||||
rawLower.includes("connection refused") ||
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = "Connection refused. Is the server running at this address?";
|
||||
detail = labels.refused;
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
detail = "Host not found. Check the hostname and try again.";
|
||||
detail = labels.hostNotFound;
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = "Host is unreachable. Check your network and firewall.";
|
||||
detail = labels.hostUnreachable;
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("tls") ||
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail =
|
||||
"TLS error. Direct connections use SSL only when a TLS terminator is in front of the daemon.";
|
||||
detail = labels.tlsError;
|
||||
} else {
|
||||
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
|
||||
detail = labels.unableToConnect;
|
||||
}
|
||||
|
||||
return { title, detail, raw };
|
||||
@@ -267,6 +291,7 @@ export interface AddHostModalProps {
|
||||
|
||||
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { probeAndUpsertDirectConnection } = useHostMutations();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
@@ -308,6 +333,23 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
() => ({ checked: useTls, disabled: isSaving }),
|
||||
[isSaving, useTls],
|
||||
);
|
||||
const directConnectionLabels = useMemo<DirectConnectionLabels>(
|
||||
() => ({
|
||||
hostRequired: t("pairing.direct.errors.hostRequired"),
|
||||
invalidPort: t("pairing.direct.errors.invalidPort"),
|
||||
invalidConnection: t("pairing.direct.errors.invalidConnection"),
|
||||
failedToConnect: (endpoint) => t("pairing.direct.errors.failedToConnect", { endpoint }),
|
||||
noAdditionalDetails: (detail) => t("pairing.direct.errors.noAdditionalDetails", { detail }),
|
||||
timedOut: t("pairing.direct.errors.timedOut"),
|
||||
refused: t("pairing.direct.errors.refused"),
|
||||
hostNotFound: t("pairing.direct.errors.hostNotFound"),
|
||||
hostUnreachable: t("pairing.direct.errors.hostUnreachable"),
|
||||
tlsError: t("pairing.direct.errors.tlsError"),
|
||||
unableToConnect: t("pairing.direct.errors.unableToConnect"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.direct.title") }), [t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
@@ -328,9 +370,13 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
|
||||
let connection: PreparedDirectConnection;
|
||||
try {
|
||||
connection = prepareDirectConnection({ host, port, useTls, password });
|
||||
connection = prepareDirectConnection(
|
||||
{ host, port, useTls, password },
|
||||
directConnectionLabels,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid connection";
|
||||
const message =
|
||||
error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
|
||||
setErrorMessage(message);
|
||||
return;
|
||||
}
|
||||
@@ -349,10 +395,20 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(connection.uri, error);
|
||||
const {
|
||||
title,
|
||||
detail,
|
||||
raw: rawDetail,
|
||||
} = buildConnectionFailureCopy({
|
||||
endpoint: connection.uri,
|
||||
error,
|
||||
labels: directConnectionLabels,
|
||||
});
|
||||
let combined: string;
|
||||
if (rawDetail && detail && rawDetail !== detail) {
|
||||
combined = `${title}\n${detail}\nDetails: ${rawDetail}`;
|
||||
combined = `${title}\n${detail}\n${t("pairing.direct.errors.details", {
|
||||
detail: rawDetail,
|
||||
})}`;
|
||||
} else if (detail) {
|
||||
combined = `${title}\n${detail}`;
|
||||
} else {
|
||||
@@ -360,13 +416,14 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
}
|
||||
setErrorMessage(combined);
|
||||
if (!isMobile) {
|
||||
Alert.alert("Connection failed", combined);
|
||||
Alert.alert(t("pairing.direct.errors.failedTitle"), combined);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
daemons,
|
||||
directConnectionLabels,
|
||||
handleClose,
|
||||
host,
|
||||
isMobile,
|
||||
@@ -375,6 +432,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
password,
|
||||
port,
|
||||
probeAndUpsertDirectConnection,
|
||||
t,
|
||||
useTls,
|
||||
]);
|
||||
|
||||
@@ -398,7 +456,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
const handleToggleAdvanced = useCallback(() => {
|
||||
if (!isAdvancedOpen) {
|
||||
try {
|
||||
setAdvancedUri(buildConnectionUriFromDraft({ host, port, useTls, password }));
|
||||
setAdvancedUri(
|
||||
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
|
||||
);
|
||||
} catch {
|
||||
setAdvancedUri("");
|
||||
}
|
||||
@@ -419,27 +479,27 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setErrorMessage("");
|
||||
}
|
||||
setIsAdvancedOpen(false);
|
||||
}, [advancedUri, host, isAdvancedOpen, password, port, useTls]);
|
||||
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
|
||||
|
||||
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
|
||||
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={DIRECT_CONNECTION_HEADER}
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
testID="add-host-modal"
|
||||
>
|
||||
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
|
||||
<Text style={styles.helper}>{t("pairing.direct.helper")}</Text>
|
||||
|
||||
<View style={styles.portRow}>
|
||||
<View style={hostFieldStyle}>
|
||||
<Text style={styles.label}>Host</Text>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-input"
|
||||
nativeID="direct-host-input"
|
||||
accessibilityLabel="Host"
|
||||
accessibilityLabel={t("pairing.direct.fields.host")}
|
||||
initialValue={host}
|
||||
resetKey={`direct-host-${inputResetKey}`}
|
||||
value={host}
|
||||
@@ -455,11 +515,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
/>
|
||||
</View>
|
||||
<View style={portFieldStyle}>
|
||||
<Text style={styles.label}>Port</Text>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
|
||||
<AdaptiveTextInput
|
||||
testID="direct-port-input"
|
||||
nativeID="direct-port-input"
|
||||
accessibilityLabel="Port"
|
||||
accessibilityLabel={t("pairing.direct.fields.port")}
|
||||
initialValue={port}
|
||||
resetKey={`direct-port-${inputResetKey}`}
|
||||
value={port}
|
||||
@@ -482,7 +542,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
onPress={handleToggleUseTls}
|
||||
disabled={isSaving}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel="Use SSL"
|
||||
accessibilityLabel={t("pairing.direct.fields.useSsl")}
|
||||
accessibilityState={useTlsAccessibilityState}
|
||||
testID="direct-ssl-toggle"
|
||||
>
|
||||
@@ -493,21 +553,21 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.label}>Use SSL</Text>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
|
||||
<View style={styles.passwordRow}>
|
||||
<AdaptiveTextInput
|
||||
testID="direct-password-input"
|
||||
nativeID="direct-password-input"
|
||||
accessibilityLabel="Password"
|
||||
accessibilityLabel={t("pairing.direct.fields.password")}
|
||||
initialValue={password}
|
||||
resetKey={`direct-password-${inputResetKey}`}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Optional"
|
||||
placeholder={t("pairing.direct.fields.optional")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={passwordInputStyle}
|
||||
autoCapitalize="none"
|
||||
@@ -522,7 +582,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
onPress={handleTogglePasswordVisibility}
|
||||
disabled={isSaving}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isPasswordVisible ? "Hide password" : "Show password"}
|
||||
accessibilityLabel={
|
||||
isPasswordVisible
|
||||
? t("pairing.direct.passwordVisibility.hide")
|
||||
: t("pairing.direct.passwordVisibility.show")
|
||||
}
|
||||
testID="direct-password-visibility-toggle"
|
||||
>
|
||||
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
|
||||
@@ -536,17 +600,19 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
onPress={handleToggleAdvanced}
|
||||
disabled={isSaving}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isAdvancedOpen ? "Hide advanced" : "Show advanced"}
|
||||
accessibilityLabel={
|
||||
isAdvancedOpen ? t("pairing.direct.advanced.hide") : t("pairing.direct.advanced.show")
|
||||
}
|
||||
testID="direct-host-advanced-toggle"
|
||||
>
|
||||
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.advancedText}>Advanced</Text>
|
||||
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
|
||||
</Pressable>
|
||||
{isAdvancedOpen ? (
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel="Connection URI"
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
initialValue={advancedUri}
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
value={advancedUri}
|
||||
@@ -572,7 +638,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
onPress={handleCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
{t("pairing.direct.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
style={FLEX_ONE_STYLE}
|
||||
@@ -582,7 +648,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
leftIcon={connectIcon}
|
||||
testID="direct-host-submit"
|
||||
>
|
||||
{isSaving ? "Connecting..." : "Connect"}
|
||||
{isSaving ? t("pairing.direct.actions.connecting") : t("pairing.direct.actions.connect")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
@@ -33,8 +35,18 @@ interface AgentListProps {
|
||||
showAttentionIndicator?: boolean;
|
||||
}
|
||||
|
||||
type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older";
|
||||
|
||||
const DATE_SECTION_ORDER = [
|
||||
"today",
|
||||
"yesterday",
|
||||
"thisWeek",
|
||||
"thisMonth",
|
||||
"older",
|
||||
] as const satisfies readonly DateSectionKey[];
|
||||
|
||||
type FlatListItem =
|
||||
| { type: "header"; key: string; title: string }
|
||||
| { type: "header"; key: string; section: DateSectionKey }
|
||||
| { type: "agent"; key: string; agent: AggregatedAgent };
|
||||
|
||||
function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent {
|
||||
@@ -94,7 +106,7 @@ function rememberArchivedAgentDetail(agent: AggregatedAgent) {
|
||||
});
|
||||
}
|
||||
|
||||
function deriveDateSectionLabel(lastActivityAt: Date): string {
|
||||
function deriveDateSectionKey(lastActivityAt: Date): DateSectionKey {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
|
||||
@@ -105,35 +117,50 @@ function deriveDateSectionLabel(lastActivityAt: Date): string {
|
||||
);
|
||||
|
||||
if (activityStart.getTime() >= todayStart.getTime()) {
|
||||
return "Today";
|
||||
return "today";
|
||||
}
|
||||
if (activityStart.getTime() >= yesterdayStart.getTime()) {
|
||||
return "Yesterday";
|
||||
return "yesterday";
|
||||
}
|
||||
|
||||
const diffTime = todayStart.getTime() - activityStart.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
if (diffDays <= 7) {
|
||||
return "This week";
|
||||
return "thisWeek";
|
||||
}
|
||||
if (diffDays <= 30) {
|
||||
return "This month";
|
||||
return "thisMonth";
|
||||
}
|
||||
return "Older";
|
||||
return "older";
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: AggregatedAgent["status"]): string {
|
||||
function formatDateSectionLabel(t: TFunction, section: DateSectionKey): string {
|
||||
switch (section) {
|
||||
case "today":
|
||||
return t("agentList.dateSections.today");
|
||||
case "yesterday":
|
||||
return t("agentList.dateSections.yesterday");
|
||||
case "thisWeek":
|
||||
return t("agentList.dateSections.thisWeek");
|
||||
case "thisMonth":
|
||||
return t("agentList.dateSections.thisMonth");
|
||||
case "older":
|
||||
return t("agentList.dateSections.older");
|
||||
}
|
||||
}
|
||||
|
||||
function formatStatusLabel(t: TFunction, status: AggregatedAgent["status"]): string {
|
||||
switch (status) {
|
||||
case "initializing":
|
||||
return "Starting";
|
||||
return t("agentList.status.initializing");
|
||||
case "idle":
|
||||
return "Idle";
|
||||
return t("agentList.status.idle");
|
||||
case "running":
|
||||
return "Running";
|
||||
return t("agentList.status.running");
|
||||
case "error":
|
||||
return "Error";
|
||||
return t("agentList.status.error");
|
||||
case "closed":
|
||||
return "Closed";
|
||||
return t("agentList.status.closed");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
@@ -188,12 +215,14 @@ function SessionRow({
|
||||
onLongPress: (agent: AggregatedAgent) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const statusLabel = formatStatusLabel(agent.status);
|
||||
const statusLabel = formatStatusLabel(t, agent.status);
|
||||
const projectPath = shortenPath(agent.cwd);
|
||||
const ProviderIcon = getProviderIcon(agent.provider);
|
||||
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -231,14 +260,19 @@ function SessionRow({
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<Text style={sessionTitleStyle} numberOfLines={1}>
|
||||
{agent.title || "New session"}
|
||||
{agent.title || t("agentList.fallbackTitle")}
|
||||
</Text>
|
||||
{agent.archivedAt ? <SessionBadge label="Archived" icon={archivedIcon} /> : null}
|
||||
{(agent.pendingPermissionCount ?? 0) > 0 ? (
|
||||
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
|
||||
{agent.archivedAt ? (
|
||||
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
|
||||
) : null}
|
||||
{pendingPermissionCount > 0 ? (
|
||||
<SessionBadge
|
||||
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
|
||||
tone="warning"
|
||||
/>
|
||||
) : null}
|
||||
{!isMobile && showAttentionIndicator && agent.requiresAttention ? (
|
||||
<SessionBadge label="Attention" tone="danger" />
|
||||
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
|
||||
) : null}
|
||||
</View>
|
||||
{isMobile && (
|
||||
@@ -272,7 +306,7 @@ function SessionRow({
|
||||
)}
|
||||
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
|
||||
<View style={styles.rowTrailing}>
|
||||
<SessionBadge label="Attention" tone="danger" />
|
||||
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
@@ -289,6 +323,7 @@ export function AgentList({
|
||||
showAttentionIndicator = true,
|
||||
}: AgentListProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
@@ -354,22 +389,21 @@ export function AgentList({
|
||||
}, [actionAgent, actionClient, archiveAgent]);
|
||||
|
||||
const flatItems = useMemo((): FlatListItem[] => {
|
||||
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
|
||||
const buckets = new Map<string, AggregatedAgent[]>();
|
||||
const buckets = new Map<DateSectionKey, AggregatedAgent[]>();
|
||||
for (const agent of agents) {
|
||||
const label = deriveDateSectionLabel(agent.lastActivityAt);
|
||||
const existing = buckets.get(label) ?? [];
|
||||
const section = deriveDateSectionKey(agent.lastActivityAt);
|
||||
const existing = buckets.get(section) ?? [];
|
||||
existing.push(agent);
|
||||
buckets.set(label, existing);
|
||||
buckets.set(section, existing);
|
||||
}
|
||||
|
||||
const result: FlatListItem[] = [];
|
||||
for (const label of order) {
|
||||
const data = buckets.get(label);
|
||||
for (const section of DATE_SECTION_ORDER) {
|
||||
const data = buckets.get(section);
|
||||
if (!data || data.length === 0) {
|
||||
continue;
|
||||
}
|
||||
result.push({ type: "header", key: `header:${label}`, title: label });
|
||||
result.push({ type: "header", key: `header:${section}`, section });
|
||||
for (const agent of data) {
|
||||
result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent });
|
||||
}
|
||||
@@ -382,7 +416,7 @@ export function AgentList({
|
||||
if (item.type === "header") {
|
||||
return (
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={styles.sectionTitle}>{item.title}</Text>
|
||||
<Text style={styles.sectionTitle}>{formatDateSectionLabel(t, item.section)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -397,7 +431,7 @@ export function AgentList({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator],
|
||||
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator, t],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
|
||||
@@ -454,8 +488,8 @@ export function AgentList({
|
||||
<View style={styles.sheetHandle} />
|
||||
<Text style={styles.sheetTitle}>
|
||||
{isActionDaemonUnavailable
|
||||
? "Host offline"
|
||||
: "This agent is still running. Archiving it will stop the agent."}
|
||||
? t("agentList.archiveSheet.hostOffline")
|
||||
: t("agentList.archiveSheet.runningAgent")}
|
||||
</Text>
|
||||
<View style={styles.sheetButtonRow}>
|
||||
<Pressable
|
||||
@@ -463,7 +497,7 @@ export function AgentList({
|
||||
onPress={handleCloseActionSheet}
|
||||
testID="agent-action-cancel"
|
||||
>
|
||||
<Text style={styles.sheetCancelText}>Cancel</Text>
|
||||
<Text style={styles.sheetCancelText}>{t("common.actions.cancel")}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
disabled={isActionDaemonUnavailable}
|
||||
@@ -471,7 +505,7 @@ export function AgentList({
|
||||
onPress={handleArchiveAgent}
|
||||
testID="agent-action-archive"
|
||||
>
|
||||
<Text style={sheetArchiveTextStyle}>Archive</Text>
|
||||
<Text style={sheetArchiveTextStyle}>{t("agentList.archiveSheet.archive")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -15,6 +16,7 @@ interface ArchivedAgentCalloutProps {
|
||||
}
|
||||
|
||||
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -43,14 +45,14 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
<View style={styles.inputAreaContainer}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
<View style={styles.callout}>
|
||||
<Text style={styles.calloutText}>This agent is archived</Text>
|
||||
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={handleUnarchive}
|
||||
disabled={!isConnected || isUnarchiving}
|
||||
>
|
||||
Unarchive
|
||||
{t("agentPanel.archived.unarchive")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -55,6 +55,17 @@ vi.mock("@/constants/platform", () => ({
|
||||
isNative: false,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
({
|
||||
"message.attachments.closeImage": "Close image",
|
||||
"message.attachments.dismissImage": "Dismiss image",
|
||||
"message.attachments.imageLoadFailed": "Couldn't load image",
|
||||
})[key] ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
}));
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Image as ExpoImage } from "expo-image";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -15,6 +16,7 @@ interface AttachmentLightboxProps {
|
||||
|
||||
export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const url = useAttachmentPreviewUrl(metadata);
|
||||
const [errored, setErrored] = useState(false);
|
||||
@@ -63,14 +65,14 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss image"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View style={styles.contentLayer}>
|
||||
<View style={styles.imageArea}>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>Couldn't load image</Text>
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
<Pressable onPress={noopPress} style={styles.imagePressable}>
|
||||
<ExpoImage
|
||||
@@ -86,7 +88,7 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
|
||||
<Pressable
|
||||
testID="attachment-lightbox-close"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close image"
|
||||
accessibilityLabel={t("message.attachments.closeImage")}
|
||||
hitSlop={8}
|
||||
onPress={onClose}
|
||||
style={closeButtonStyle}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Pressable, View, type PressableStateCallbackType } from "react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||
import type { ComboboxProps } from "@/components/ui/combobox";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -27,6 +28,7 @@ export function BranchSwitcher({
|
||||
isGitCheckout,
|
||||
}: BranchSwitcherProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
@@ -91,7 +93,7 @@ export function BranchSwitcher({
|
||||
onPress={handleOpen}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
||||
accessibilityLabel={t("branchSwitcher.currentBranch", { branchName: currentBranchName })}
|
||||
>
|
||||
{titleContent}
|
||||
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
|
||||
@@ -101,10 +103,10 @@ export function BranchSwitcher({
|
||||
value={currentBranchName}
|
||||
onSelect={handleBranchSelect}
|
||||
searchable
|
||||
placeholder="Switch branch..."
|
||||
searchPlaceholder="Filter branches..."
|
||||
emptyText="No branches found."
|
||||
title="Switch branch"
|
||||
placeholder={t("branchSwitcher.placeholder")}
|
||||
searchPlaceholder={t("branchSwitcher.searchPlaceholder")}
|
||||
emptyText={t("branchSwitcher.empty")}
|
||||
title={t("branchSwitcher.title")}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
anchorRef={anchorRef}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Pressable, Text, TextInput, View } from "react-native";
|
||||
import { ArrowLeft, ArrowRight, MousePointer2, PencilRuler, RotateCw } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
buildWorkspaceAttachmentScopeKey,
|
||||
useWorkspaceAttachments,
|
||||
@@ -55,7 +56,7 @@ function truncateText(value: string, maxLength: number): string {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength).trim()}...` : value;
|
||||
}
|
||||
|
||||
function getWebviewLoadErrorMessage(event: Event): string | null {
|
||||
function getWebviewLoadErrorMessage(event: Event, failedToLoadLabel: string): string | null {
|
||||
const details = event as Event & {
|
||||
errorCode?: unknown;
|
||||
errorDescription?: unknown;
|
||||
@@ -69,7 +70,7 @@ function getWebviewLoadErrorMessage(event: Event): string | null {
|
||||
const description =
|
||||
typeof details.errorDescription === "string" && details.errorDescription.trim()
|
||||
? details.errorDescription.trim()
|
||||
: "Failed to load page";
|
||||
: failedToLoadLabel;
|
||||
const url =
|
||||
typeof details.validatedURL === "string" && details.validatedURL.trim()
|
||||
? details.validatedURL.trim()
|
||||
@@ -78,7 +79,7 @@ function getWebviewLoadErrorMessage(event: Event): string | null {
|
||||
return url ? `${description}: ${url}` : description;
|
||||
}
|
||||
|
||||
function getLoadUrlRejectionMessage(error: unknown): string | null {
|
||||
function getLoadUrlRejectionMessage(error: unknown, failedToLoadLabel: string): string | null {
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
if (error.message.includes("ERR_ABORTED") || error.message.includes("ERR_BLOCKED_BY_CLIENT")) {
|
||||
return null;
|
||||
@@ -91,18 +92,21 @@ function getLoadUrlRejectionMessage(error: unknown): string | null {
|
||||
}
|
||||
return error.trim();
|
||||
}
|
||||
return "Failed to load page";
|
||||
return failedToLoadLabel;
|
||||
}
|
||||
|
||||
function getUnsafeNavigationMessage(url: string): string | null {
|
||||
function getUnsafeNavigationMessage(
|
||||
url: string,
|
||||
labels: { invalidUrl: string; unsupportedProtocol: (protocol: string) => string },
|
||||
): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (ALLOWED_BROWSER_PROTOCOLS.has(parsed.protocol) || parsed.href === "about:blank") {
|
||||
return null;
|
||||
}
|
||||
return `Blocked unsupported browser URL: ${parsed.protocol}`;
|
||||
return labels.unsupportedProtocol(parsed.protocol);
|
||||
} catch {
|
||||
return "Invalid browser URL";
|
||||
return labels.invalidUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +287,7 @@ export function BrowserPane({
|
||||
onFocusPane?: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const browser = useBrowserStore((state) => state.browsersById[browserId] ?? null);
|
||||
const updateBrowser = useBrowserStore((state) => state.updateBrowser);
|
||||
const webviewRef = useRef<ElectronWebview | null>(null);
|
||||
@@ -327,6 +332,17 @@ export function BrowserPane({
|
||||
() => [styles.metaError, { color: theme.colors.palette.red[500] }],
|
||||
[theme.colors.palette.red],
|
||||
);
|
||||
const browserErrorLabels = useMemo(
|
||||
() => ({
|
||||
failedToLoad: t("workspace.browser.errors.failedToLoad"),
|
||||
invalidUrl: t("workspace.browser.errors.invalidUrl"),
|
||||
unsupportedProtocol: (protocol: string) =>
|
||||
t("workspace.browser.errors.unsupportedProtocol", { protocol }),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const browserErrorLabelsRef = useRef(browserErrorLabels);
|
||||
browserErrorLabelsRef.current = browserErrorLabels;
|
||||
|
||||
useEffect(() => {
|
||||
const nextUrl = browser?.url ?? "https://example.com";
|
||||
@@ -384,7 +400,10 @@ export function BrowserPane({
|
||||
|
||||
host.replaceChildren();
|
||||
|
||||
const initialUnsafeNavigationMessage = getUnsafeNavigationMessage(initialUrlRef.current);
|
||||
const initialUnsafeNavigationMessage = getUnsafeNavigationMessage(
|
||||
initialUrlRef.current,
|
||||
browserErrorLabelsRef.current,
|
||||
);
|
||||
const webview = document.createElement("webview") as ElectronWebview;
|
||||
webviewRef.current = webview;
|
||||
webview.setAttribute("partition", `persist:paseo-browser-${browserId}`);
|
||||
@@ -459,7 +478,7 @@ export function BrowserPane({
|
||||
updateBrowserRef.current(browserIdRef.current, { faviconUrl: favicons[0] ?? null });
|
||||
};
|
||||
const handleLoadFailed = (event: Event) => {
|
||||
const message = getWebviewLoadErrorMessage(event);
|
||||
const message = getWebviewLoadErrorMessage(event, browserErrorLabelsRef.current.failedToLoad);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
@@ -519,43 +538,46 @@ export function BrowserPane({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [browserId, onFocusPane]);
|
||||
|
||||
const navigate = useCallback((nextUrl: string) => {
|
||||
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
|
||||
const webview = webviewRef.current;
|
||||
const unsafeNavigationMessage = getUnsafeNavigationMessage(normalizedUrl);
|
||||
const previousUrl = browserRef.current?.url ?? initialUrlRef.current;
|
||||
pendingNavigationUrlRef.current = unsafeNavigationMessage ? null : normalizedUrl;
|
||||
updateBrowserRef.current(browserIdRef.current, {
|
||||
url: normalizedUrl,
|
||||
isLoading: unsafeNavigationMessage === null,
|
||||
...(normalizedUrl !== previousUrl ? { faviconUrl: null } : {}),
|
||||
lastError: null,
|
||||
});
|
||||
setDraftUrl((current) => (current === normalizedUrl ? current : normalizedUrl));
|
||||
if (unsafeNavigationMessage) {
|
||||
const navigate = useCallback(
|
||||
(nextUrl: string) => {
|
||||
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
|
||||
const webview = webviewRef.current;
|
||||
const unsafeNavigationMessage = getUnsafeNavigationMessage(normalizedUrl, browserErrorLabels);
|
||||
const previousUrl = browserRef.current?.url ?? initialUrlRef.current;
|
||||
pendingNavigationUrlRef.current = unsafeNavigationMessage ? null : normalizedUrl;
|
||||
updateBrowserRef.current(browserIdRef.current, {
|
||||
isLoading: false,
|
||||
lastError: unsafeNavigationMessage,
|
||||
url: normalizedUrl,
|
||||
isLoading: unsafeNavigationMessage === null,
|
||||
...(normalizedUrl !== previousUrl ? { faviconUrl: null } : {}),
|
||||
lastError: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (webview?.loadURL) {
|
||||
void webview.loadURL(normalizedUrl).catch((error: unknown) => {
|
||||
const message = getLoadUrlRejectionMessage(error);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
setDraftUrl((current) => (current === normalizedUrl ? current : normalizedUrl));
|
||||
if (unsafeNavigationMessage) {
|
||||
updateBrowserRef.current(browserIdRef.current, {
|
||||
isLoading: false,
|
||||
lastError: message,
|
||||
lastError: unsafeNavigationMessage,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (webview) {
|
||||
webview.setAttribute("src", normalizedUrl);
|
||||
}
|
||||
}, []);
|
||||
return;
|
||||
}
|
||||
if (webview?.loadURL) {
|
||||
void webview.loadURL(normalizedUrl).catch((error: unknown) => {
|
||||
const message = getLoadUrlRejectionMessage(error, browserErrorLabels.failedToLoad);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
updateBrowserRef.current(browserIdRef.current, {
|
||||
isLoading: false,
|
||||
lastError: message,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (webview) {
|
||||
webview.setAttribute("src", normalizedUrl);
|
||||
}
|
||||
},
|
||||
[browserErrorLabels],
|
||||
);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
webviewRef.current?.goBack?.();
|
||||
@@ -929,10 +951,8 @@ export function BrowserPane({
|
||||
if (!isElectronRuntime()) {
|
||||
return (
|
||||
<View style={styles.unavailableState}>
|
||||
<Text style={titleStyle}>Browser is desktop-only</Text>
|
||||
<Text style={subtitleStyle}>
|
||||
Open this workspace in Electron to use the built-in browser.
|
||||
</Text>
|
||||
<Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
|
||||
<Text style={subtitleStyle}>{t("workspace.browser.unavailable.subtitle")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -943,7 +963,7 @@ export function BrowserPane({
|
||||
<View style={styles.chromeLeft}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back"
|
||||
accessibilityLabel={t("workspace.browser.controls.back")}
|
||||
disabled={!browser?.canGoBack}
|
||||
onPress={handleBack}
|
||||
style={backIconButtonStyle}
|
||||
@@ -952,7 +972,7 @@ export function BrowserPane({
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Forward"
|
||||
accessibilityLabel={t("workspace.browser.controls.forward")}
|
||||
disabled={!browser?.canGoForward}
|
||||
onPress={handleForward}
|
||||
style={forwardIconButtonStyle}
|
||||
@@ -961,7 +981,11 @@ export function BrowserPane({
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={browser?.isLoading ? "Stop loading" : "Refresh"}
|
||||
accessibilityLabel={
|
||||
browser?.isLoading
|
||||
? t("workspace.browser.controls.stopLoading")
|
||||
: t("workspace.browser.controls.refresh")
|
||||
}
|
||||
onPress={handleRefresh}
|
||||
style={baseIconButtonStyle}
|
||||
>
|
||||
@@ -970,13 +994,13 @@ export function BrowserPane({
|
||||
</View>
|
||||
<View style={styles.urlBarWrap}>
|
||||
<TextInput
|
||||
accessibilityLabel="Browser URL"
|
||||
accessibilityLabel={t("workspace.browser.controls.browserUrl")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onChangeText={setDraftUrl}
|
||||
onFocus={handleUrlBarFocus}
|
||||
onSubmitEditing={handleNavigateDraftUrl}
|
||||
placeholder="Enter URL"
|
||||
placeholder={t("workspace.browser.controls.enterUrl")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
ref={urlInputRef}
|
||||
style={urlInputStyle}
|
||||
@@ -988,7 +1012,7 @@ export function BrowserPane({
|
||||
<>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open browser dev tools"
|
||||
accessibilityLabel={t("workspace.browser.controls.openDevTools")}
|
||||
onPress={handleOpenDevTools}
|
||||
style={baseIconButtonStyle}
|
||||
>
|
||||
@@ -996,7 +1020,11 @@ export function BrowserPane({
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={selectorActive ? "Cancel element selector" : "Select element"}
|
||||
accessibilityLabel={
|
||||
selectorActive
|
||||
? t("workspace.browser.controls.cancelSelector")
|
||||
: t("workspace.browser.controls.selectElement")
|
||||
}
|
||||
onPress={handleToggleElementSelector}
|
||||
style={selectorIconButtonStyle}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface BrowserPaneProps {
|
||||
browserId: string;
|
||||
@@ -13,6 +14,7 @@ interface BrowserPaneProps {
|
||||
|
||||
export function BrowserPane({ browserId }: BrowserPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
@@ -24,8 +26,8 @@ export function BrowserPane({ browserId }: BrowserPaneProps) {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={titleStyle}>Browser is desktop-only</Text>
|
||||
<Text style={subtitleStyle}>Browser session {browserId}</Text>
|
||||
<Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
|
||||
<Text style={subtitleStyle}>{t("workspace.browser.session", { browserId })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
interface BrowserPaneProps {
|
||||
@@ -12,6 +13,7 @@ interface BrowserPaneProps {
|
||||
}
|
||||
|
||||
export function BrowserPane({ browserId }: BrowserPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
@@ -24,11 +26,9 @@ export function BrowserPane({ browserId }: BrowserPaneProps) {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={titleStyle}>Browser is desktop-only</Text>
|
||||
<Text style={subtitleStyle}>
|
||||
Open this workspace in Electron to use the built-in browser.
|
||||
</Text>
|
||||
<Text style={subtitleStyle}>Browser session {browserId}</Text>
|
||||
<Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
|
||||
<Text style={subtitleStyle}>{t("workspace.browser.unavailable.subtitle")}</Text>
|
||||
<Text style={subtitleStyle}>{t("workspace.browser.session", { browserId })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -144,6 +145,7 @@ function ModelRow({
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const ProviderIcon = getProviderIcon(row.provider);
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
@@ -166,7 +168,9 @@ function ModelRow({
|
||||
hitSlop={8}
|
||||
style={favoriteButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
|
||||
accessibilityLabel={
|
||||
isFavorite ? t("modelSelector.unfavoriteModel") : t("modelSelector.favoriteModel")
|
||||
}
|
||||
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
@@ -193,6 +197,7 @@ function ModelRow({
|
||||
theme.colors.palette.amber,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.colors.border,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -256,6 +261,7 @@ function FavoritesSection({
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (favoriteRows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -263,7 +269,7 @@ function FavoritesSection({
|
||||
return (
|
||||
<View style={styles.favoritesContainer}>
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={styles.sectionHeadingText}>Favorites</Text>
|
||||
<Text style={styles.sectionHeadingText}>{t("modelSelector.favorites")}</Text>
|
||||
</View>
|
||||
{favoriteRows.map((row) => (
|
||||
<SelectableModelRow
|
||||
@@ -295,6 +301,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
|
||||
|
||||
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const ProvIcon = getProviderIcon(provider.id);
|
||||
const selection = provider.modelSelection;
|
||||
|
||||
@@ -306,7 +313,11 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
|
||||
if (selection.kind === "models") {
|
||||
const count = selection.rows.length;
|
||||
stateNode = (
|
||||
<Text style={styles.drillDownCount}>{`${count} ${count === 1 ? "model" : "models"}`}</Text>
|
||||
<Text style={styles.drillDownCount}>
|
||||
{t(count === 1 ? "modelSelector.modelCount" : "modelSelector.modelCountPlural", {
|
||||
count,
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
} else if (selection.kind === "loading") {
|
||||
stateNode = (
|
||||
@@ -316,14 +327,14 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={styles.rowSpinner}
|
||||
/>
|
||||
<Text style={styles.drillDownCount}>Loading</Text>
|
||||
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
stateNode = (
|
||||
<View style={styles.rowStateInline}>
|
||||
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownCount}>Error</Text>
|
||||
<Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -431,6 +442,7 @@ function ProviderErrorEmptyState({
|
||||
isRetryingProvider: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const handleRetry = useCallback(() => {
|
||||
onRetryProvider?.(providerId);
|
||||
}, [onRetryProvider, providerId]);
|
||||
@@ -440,7 +452,7 @@ function ProviderErrorEmptyState({
|
||||
<Text style={styles.emptyStateText}>{message}</Text>
|
||||
{onRetryProvider ? (
|
||||
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
|
||||
{isRetryingProvider ? "Retrying…" : "Retry"}
|
||||
{isRetryingProvider ? t("modelSelector.retrying") : t("modelSelector.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -461,6 +473,7 @@ function SelectorContent({
|
||||
isRetryingProvider,
|
||||
}: SelectorContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||
const selectedViewProvider = useMemo(
|
||||
() =>
|
||||
@@ -484,7 +497,7 @@ function SelectorContent({
|
||||
const emptyState = (
|
||||
<View style={styles.emptyState}>
|
||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||
<Text style={styles.emptyStateText}>{t("modelSelector.noMatches")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -501,7 +514,7 @@ function SelectorContent({
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={styles.rowSpinner}
|
||||
/>
|
||||
<Text style={styles.emptyStateText}>Loading</Text>
|
||||
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -569,6 +582,7 @@ export function CombinedModelSelector({
|
||||
serverId = null,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
|
||||
@@ -653,12 +667,15 @@ export function CombinedModelSelector({
|
||||
}, [providers, view]);
|
||||
|
||||
const triggerLabel = useMemo(() => {
|
||||
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
|
||||
if (
|
||||
selectedModelLabel === t("modelSelector.loading") ||
|
||||
selectedModelLabel === t("modelSelector.selectModel")
|
||||
) {
|
||||
return selectedModelLabel;
|
||||
}
|
||||
|
||||
return buildSelectedTriggerLabel(selectedModelLabel);
|
||||
}, [selectedModelLabel]);
|
||||
}, [selectedModelLabel, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (platformIsWeb) {
|
||||
@@ -713,7 +730,7 @@ export function CombinedModelSelector({
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(() => {
|
||||
if (view.kind === "all") {
|
||||
return { title: "Select provider" };
|
||||
return { title: t("modelSelector.title") };
|
||||
}
|
||||
const ProviderIconForView = getProviderIcon(view.providerId);
|
||||
const headerActions = (
|
||||
@@ -723,7 +740,9 @@ export function CombinedModelSelector({
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${view.providerLabel} settings`}
|
||||
accessibilityLabel={t("modelSelector.openProviderSettings", {
|
||||
provider: view.providerLabel,
|
||||
})}
|
||||
testID={`selector-header-settings-${view.providerId}`}
|
||||
>
|
||||
<Settings
|
||||
@@ -742,7 +761,7 @@ export function CombinedModelSelector({
|
||||
search: {
|
||||
onChange: handleSearchQueryChange,
|
||||
resetKey: `${view.providerId}:${searchResetKey}`,
|
||||
placeholder: "Search models...",
|
||||
placeholder: t("modelSelector.searchPlaceholder"),
|
||||
autoFocus: platformIsWeb,
|
||||
testID: "model-search-input",
|
||||
},
|
||||
@@ -757,6 +776,7 @@ export function CombinedModelSelector({
|
||||
handleBackToAll,
|
||||
handleSearchQueryChange,
|
||||
searchResetKey,
|
||||
t,
|
||||
theme.iconSize.md,
|
||||
theme.iconSize.sm,
|
||||
theme.colors.foreground,
|
||||
@@ -771,7 +791,7 @@ export function CombinedModelSelector({
|
||||
onPress={handleTriggerPress}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select model (${selectedModelLabel})`}
|
||||
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
||||
testID="combined-model-selector"
|
||||
>
|
||||
{renderTrigger ? (
|
||||
@@ -823,7 +843,7 @@ export function CombinedModelSelector({
|
||||
) : (
|
||||
<View style={styles.sheetLoadingState}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sheetLoadingText}>Loading model selector…</Text>
|
||||
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Combobox>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Home, Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
@@ -202,6 +203,7 @@ interface CommandCenterAgentRowContentProps {
|
||||
|
||||
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
@@ -222,7 +224,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<Text style={titleStyle} numberOfLines={1}>
|
||||
{agent.title || "New agent"}
|
||||
{agent.title || t("shell.commandCenter.newAgent")}
|
||||
</Text>
|
||||
<Text style={subtitleStyle} numberOfLines={1}>
|
||||
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
|
||||
@@ -256,10 +258,12 @@ function AgentItemsSection({
|
||||
sectionDividerStyle,
|
||||
sectionLabelStyle,
|
||||
}: AgentItemsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionItemsLength > 0 ? <View style={sectionDividerStyle} /> : null}
|
||||
<Text style={sectionLabelStyle}>Agents</Text>
|
||||
<Text style={sectionLabelStyle}>{t("shell.commandCenter.agents")}</Text>
|
||||
{agentItems.map((item, index) => {
|
||||
const rowIndex = actionItemsLength + index;
|
||||
const agent = item.agent;
|
||||
@@ -283,6 +287,7 @@ function AgentItemsSection({
|
||||
|
||||
export function CommandCenter() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
open,
|
||||
inputRef,
|
||||
@@ -442,12 +447,12 @@ export function CommandCenter() {
|
||||
|
||||
const resultList =
|
||||
items.length === 0 ? (
|
||||
<Text style={emptyTextStyle}>No matches</Text>
|
||||
<Text style={emptyTextStyle}>{t("shell.commandCenter.noMatches")}</Text>
|
||||
) : (
|
||||
<>
|
||||
{actionItems.length > 0 ? (
|
||||
<>
|
||||
<Text style={sectionLabelStyle}>Actions</Text>
|
||||
<Text style={sectionLabelStyle}>{t("shell.commandCenter.actions")}</Text>
|
||||
{actionItems.map((item, index) => (
|
||||
<CommandCenterActionRow
|
||||
key={`action:${item.action.id}`}
|
||||
@@ -501,7 +506,7 @@ export function CommandCenter() {
|
||||
onChangeText={setQuery}
|
||||
onKeyPress={handleKeyPress}
|
||||
onSubmitEditing={handleSubmitEditing}
|
||||
placeholder="Type a command or search agents..."
|
||||
placeholder={t("shell.commandCenter.placeholder")}
|
||||
style={inputStyle}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -534,7 +539,7 @@ export function CommandCenter() {
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Type a command or search agents..."
|
||||
placeholder={t("shell.commandCenter.placeholder")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={inputStyle}
|
||||
autoCapitalize="none"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface ContextWindowMeterProps {
|
||||
@@ -81,6 +82,7 @@ export function ContextWindowMeter({
|
||||
showPercentage = false,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||
|
||||
if (percentage === null) {
|
||||
@@ -106,7 +108,9 @@ export function ContextWindowMeter({
|
||||
<Pressable
|
||||
style={containerStyle}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={`Context window ${roundedPercentage}% used`}
|
||||
accessibilityLabel={t("contextWindow.accessibility", {
|
||||
percentage: roundedPercentage,
|
||||
})}
|
||||
>
|
||||
<Svg
|
||||
width={svgSize}
|
||||
@@ -143,13 +147,20 @@ export function ContextWindowMeter({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipContent}>
|
||||
<Text style={styles.tooltipTitle}>Context window</Text>
|
||||
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
|
||||
<Text
|
||||
style={styles.tooltipDetail}
|
||||
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
|
||||
<Text style={styles.tooltipTitle}>{t("contextWindow.title")}</Text>
|
||||
<Text style={styles.tooltipText}>
|
||||
{t("contextWindow.used", { percentage: roundedPercentage })}
|
||||
</Text>
|
||||
<Text style={styles.tooltipDetail}>
|
||||
{t("contextWindow.tokens", {
|
||||
used: formatTokenCount(usedTokens),
|
||||
max: formatTokenCount(maxTokens),
|
||||
})}
|
||||
</Text>
|
||||
{formattedSessionCost ? (
|
||||
<Text style={styles.tooltipDetail}>{`Session cost ${formattedSessionCost}`}</Text>
|
||||
<Text style={styles.tooltipDetail}>
|
||||
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from "react";
|
||||
import { View, Text, Pressable, ActivityIndicator } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VolumeMeter } from "./volume-meter";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import type { DictationStatus } from "@/hooks/use-dictation";
|
||||
@@ -43,6 +44,7 @@ export function DictationControls({
|
||||
disabled = false,
|
||||
}: DictationControlsProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isFailed = status === "failed";
|
||||
const showActiveState = isRecording || isProcessing || isFailed;
|
||||
const actionsDisabled = isProcessing;
|
||||
@@ -71,7 +73,7 @@ export function DictationControls({
|
||||
onPress={onStart}
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Start voice dictation"
|
||||
accessibilityLabel={t("message.dictation.start")}
|
||||
style={micButtonStyle}
|
||||
>
|
||||
<Mic size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
@@ -89,7 +91,7 @@ export function DictationControls({
|
||||
<Pressable
|
||||
onPress={handleCancel}
|
||||
disabled={actionsDisabled && !isFailed}
|
||||
accessibilityLabel="Cancel dictation"
|
||||
accessibilityLabel={t("message.dictation.cancel")}
|
||||
style={cancelButtonStyle}
|
||||
>
|
||||
<X size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
@@ -102,7 +104,7 @@ export function DictationControls({
|
||||
{!actionsDisabled && isFailed ? (
|
||||
<Pressable
|
||||
onPress={onRetry}
|
||||
accessibilityLabel="Retry dictation"
|
||||
accessibilityLabel={t("message.dictation.retry")}
|
||||
style={ACTION_CONFIRM_STYLE}
|
||||
>
|
||||
<RefreshCcw size={theme.iconSize.sm} color={theme.colors.surface0} />
|
||||
@@ -112,14 +114,14 @@ export function DictationControls({
|
||||
<>
|
||||
<Pressable
|
||||
onPress={onAccept}
|
||||
accessibilityLabel="Insert transcription"
|
||||
accessibilityLabel={t("message.dictation.insert")}
|
||||
style={ACTION_SECONDARY_STYLE}
|
||||
>
|
||||
<Check size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onAcceptAndSend}
|
||||
accessibilityLabel="Insert transcription and send"
|
||||
accessibilityLabel={t("message.dictation.insertAndSend")}
|
||||
style={ACTION_CONFIRM_STYLE}
|
||||
>
|
||||
<ArrowUp size={theme.iconSize.sm} color={theme.colors.surface0} />
|
||||
@@ -149,6 +151,7 @@ export function DictationOverlay({
|
||||
onDiscard,
|
||||
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & { errorText?: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isFailed = status === "failed";
|
||||
const showActiveState = isRecording || isProcessing || isFailed;
|
||||
const actionsDisabled = isProcessing;
|
||||
@@ -189,7 +192,7 @@ export function DictationOverlay({
|
||||
onPress={handleCancel}
|
||||
disabled={actionsDisabled && !isFailed}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel dictation"
|
||||
accessibilityLabel={t("message.dictation.cancel")}
|
||||
style={overlayCancelButtonStyle}
|
||||
>
|
||||
<X size={theme.iconSize.lg} color={theme.colors.accentForeground} strokeWidth={2.5} />
|
||||
@@ -208,7 +211,9 @@ export function DictationOverlay({
|
||||
</View>
|
||||
{isFailed ? (
|
||||
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
|
||||
{errorText ? `Dictation failed: ${errorText}` : "Dictation failed. Tap retry."}
|
||||
{errorText
|
||||
? t("message.dictation.failed", { error: errorText })
|
||||
: t("message.dictation.failedRetry")}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -223,7 +228,7 @@ export function DictationOverlay({
|
||||
<Pressable
|
||||
onPress={onRetry}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Retry dictation"
|
||||
accessibilityLabel={t("message.dictation.retry")}
|
||||
style={overlayRetryButtonStyle}
|
||||
>
|
||||
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
|
||||
@@ -234,7 +239,7 @@ export function DictationOverlay({
|
||||
<Pressable
|
||||
onPress={onAccept}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Insert transcription"
|
||||
accessibilityLabel={t("message.dictation.insert")}
|
||||
style={OVERLAY_ACCEPT_BUTTON_STYLE}
|
||||
>
|
||||
<Pencil
|
||||
@@ -246,7 +251,7 @@ export function DictationOverlay({
|
||||
<Pressable
|
||||
onPress={onAcceptAndSend}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Insert transcription and send"
|
||||
accessibilityLabel={t("message.dictation.insertAndSend")}
|
||||
style={overlayConfirmButtonStyle}
|
||||
>
|
||||
<ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, ScrollView as RNScrollView } from "react-native";
|
||||
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -120,10 +121,12 @@ function DiffSegment({
|
||||
export function DiffViewer({
|
||||
diffLines,
|
||||
maxHeight,
|
||||
emptyLabel = "No changes to display",
|
||||
emptyLabel,
|
||||
fillAvailableHeight = false,
|
||||
}: DiffViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
|
||||
const resolvedEmptyLabel = emptyLabel ?? t("diffViewer.empty");
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const handleInnerLayout = React.useCallback(
|
||||
(e: { nativeEvent: { layout: { width: number } } }) =>
|
||||
@@ -159,7 +162,7 @@ export function DiffViewer({
|
||||
if (!diffLines.length) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyText}>{emptyLabel}</Text>
|
||||
<Text style={styles.emptyText}>{resolvedEmptyLabel}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -7,19 +9,20 @@ import { useDownloadStore, formatSpeed, formatEta, type Download } from "@/store
|
||||
|
||||
const AUTO_DISMISS_DELAY = 3000;
|
||||
|
||||
function getDownloadStatusText(download: Download): string {
|
||||
function getDownloadStatusText(download: Download, t: TFunction): string {
|
||||
if (download.status === "downloading") {
|
||||
if (download.progress) {
|
||||
return `${Math.round(download.progress.percent * 100)}% · ${formatSpeed(download.progress.speed)} · ${formatEta(download.progress.eta)}`;
|
||||
}
|
||||
return "Starting...";
|
||||
return t("common.states.starting");
|
||||
}
|
||||
if (download.status === "complete") return "Download complete";
|
||||
return download.message ?? "Download failed";
|
||||
if (download.status === "complete") return t("common.states.downloadComplete");
|
||||
return download.message ?? t("common.states.downloadFailed");
|
||||
}
|
||||
|
||||
export function DownloadToast() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const downloads = useDownloadStore((state) => state.downloads);
|
||||
const activeDownloadId = useDownloadStore((state) => state.activeDownloadId);
|
||||
@@ -78,7 +81,7 @@ export function DownloadToast() {
|
||||
<Text style={styles.fileName} numberOfLines={1}>
|
||||
{activeDownload.fileName}
|
||||
</Text>
|
||||
<Text style={styles.status}>{getDownloadStatusText(activeDownload)}</Text>
|
||||
<Text style={styles.status}>{getDownloadStatusText(activeDownload, t)}</Text>
|
||||
{activeDownload.status === "downloading" && activeDownload.progress && (
|
||||
<View style={styles.progressBar}>
|
||||
<ProgressFill percent={activeDownload.progress.percent} />
|
||||
|
||||
@@ -12,6 +12,7 @@ import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-nativ
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { PrPane } from "@/git/pr-pane";
|
||||
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
|
||||
@@ -418,6 +419,7 @@ function SidebarContent({
|
||||
onOpenFile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const padding = useWindowControlsPadding("explorerSidebar");
|
||||
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
|
||||
const prPane = usePrPaneData({
|
||||
@@ -448,7 +450,7 @@ function SidebarContent({
|
||||
<ExplorerTabButton
|
||||
tab="changes"
|
||||
active={resolvedTab === "changes"}
|
||||
label="Changes"
|
||||
label={t("workspace.tabs.explorer.changes")}
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-changes"
|
||||
/>
|
||||
@@ -456,7 +458,7 @@ function SidebarContent({
|
||||
<ExplorerTabButton
|
||||
tab="files"
|
||||
active={resolvedTab === "files"}
|
||||
label="Files"
|
||||
label={t("workspace.tabs.explorer.files")}
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-files"
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Upload } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -16,6 +17,7 @@ interface FileDropZoneProps {
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { isDragging, containerRef } = useFileDropZone({
|
||||
onFilesDropped,
|
||||
@@ -58,7 +60,7 @@ export function FileDropZone({ children, onFilesDropped, disabled = false }: Fil
|
||||
{/* Content */}
|
||||
<View style={styles.overlayContent}>
|
||||
<Upload size={32} color={theme.colors.primary} />
|
||||
<Text style={styles.overlayText}>Drop images here</Text>
|
||||
<Text style={styles.overlayText}>{t("composer.attachments.dropImagesHere")}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObject } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
@@ -45,10 +46,10 @@ import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: "name", label: "Name" },
|
||||
{ value: "modified", label: "Modified" },
|
||||
{ value: "size", label: "Size" },
|
||||
const SORT_OPTIONS: { value: SortOption }[] = [
|
||||
{ value: "name" },
|
||||
{ value: "modified" },
|
||||
{ value: "size" },
|
||||
];
|
||||
|
||||
const INDENT_PER_LEVEL = 16;
|
||||
@@ -115,6 +116,7 @@ function TreeRowItem({
|
||||
onDownloadEntry,
|
||||
}: TreeRowItemProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isDirectory = entry.kind === "directory";
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -181,7 +183,7 @@ function TreeRowItem({
|
||||
<View style={styles.contextMetaBlock}>
|
||||
<View style={styles.contextMetaRow}>
|
||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||
Size
|
||||
{t("workspace.fileExplorer.context.size")}
|
||||
</Text>
|
||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||
{formatFileSize({ size: entry.size })}
|
||||
@@ -189,7 +191,7 @@ function TreeRowItem({
|
||||
</View>
|
||||
<View style={styles.contextMetaRow}>
|
||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||
Modified
|
||||
{t("workspace.fileExplorer.context.modified")}
|
||||
</Text>
|
||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||
{formatTimeAgo(new Date(entry.modifiedAt))}
|
||||
@@ -198,11 +200,11 @@ function TreeRowItem({
|
||||
</View>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
|
||||
Copy path
|
||||
{t("workspace.fileExplorer.context.copyPath")}
|
||||
</DropdownMenuItem>
|
||||
{entry.kind === "file" ? (
|
||||
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
|
||||
Download
|
||||
{t("workspace.fileExplorer.context.download")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
@@ -229,6 +231,7 @@ export function FileExplorerPane({
|
||||
workspaceRoot,
|
||||
onOpenFile,
|
||||
}: FileExplorerPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
|
||||
@@ -395,7 +398,15 @@ export function FileExplorerPane({
|
||||
void refetchExplorer();
|
||||
}, [refetchExplorer]);
|
||||
|
||||
const currentSortLabel = resolveCurrentSortLabel(sortOption);
|
||||
const sortLabels = useMemo(
|
||||
() => ({
|
||||
name: t("workspace.fileExplorer.sort.name"),
|
||||
modified: t("workspace.fileExplorer.sort.modified"),
|
||||
size: t("workspace.fileExplorer.sort.size"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels);
|
||||
|
||||
const treeRows = useMemo(
|
||||
() => resolveTreeRows({ directories, expandedPaths, sortOption }),
|
||||
@@ -453,7 +464,7 @@ export function FileExplorerPane({
|
||||
if (!hasWorkspaceScope) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>Workspace is unavailable</Text>
|
||||
<Text style={styles.errorText}>{t("workspace.fileExplorer.states.unavailable")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -503,6 +514,7 @@ interface FileExplorerPaneContentProps {
|
||||
|
||||
function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
error,
|
||||
showInitialLoading,
|
||||
@@ -529,11 +541,11 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
<View style={styles.errorActions}>
|
||||
{showBackFromError ? (
|
||||
<Pressable style={styles.retryButton} onPress={handleBackFromError}>
|
||||
<Text style={styles.retryButtonText}>Back</Text>
|
||||
<Text style={styles.retryButtonText}>{t("workspace.fileExplorer.actions.back")}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable style={styles.retryButton} onPress={handleRetry}>
|
||||
<Text style={styles.retryButtonText}>Retry</Text>
|
||||
<Text style={styles.retryButtonText}>{t("workspace.fileExplorer.actions.retry")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -544,7 +556,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading files…</Text>
|
||||
<Text style={styles.loadingText}>{t("workspace.fileExplorer.states.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -552,7 +564,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
if (treeRows.length === 0) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>No files</Text>
|
||||
<Text style={styles.emptyText}>{t("workspace.fileExplorer.empty.noFiles")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -570,7 +582,11 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
hitSlop={8}
|
||||
style={iconButtonStyleProp}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
|
||||
accessibilityLabel={
|
||||
isRefreshFetching
|
||||
? t("workspace.fileExplorer.actions.refreshing")
|
||||
: t("workspace.fileExplorer.actions.refresh")
|
||||
}
|
||||
>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshFetching ? (
|
||||
@@ -704,8 +720,11 @@ function resolveShowInitialLoading({
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCurrentSortLabel(sortOption: SortOption): string {
|
||||
return SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
||||
function resolveCurrentSortLabel(
|
||||
sortOption: SortOption,
|
||||
labels: Record<SortOption, string>,
|
||||
): string {
|
||||
return labels[sortOption] ?? labels.name;
|
||||
}
|
||||
|
||||
function resolveTreeRows({
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
@@ -503,6 +504,7 @@ function FilePreviewBody({
|
||||
imagePreviewUri,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
||||
@@ -562,7 +564,7 @@ function FilePreviewBody({
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading file…</Text>
|
||||
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -570,7 +572,7 @@ function FilePreviewBody({
|
||||
if (!preview) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>No preview available</Text>
|
||||
<Text style={styles.emptyText}>{t("panels.file.noPreview")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -659,7 +661,7 @@ function FilePreviewBody({
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading file…</Text>
|
||||
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -689,7 +691,7 @@ function FilePreviewBody({
|
||||
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
||||
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -704,6 +706,7 @@ export function FilePane({
|
||||
workspaceRoot: string;
|
||||
location: WorkspaceFileLocation;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
|
||||
@@ -726,7 +729,10 @@ export function FilePane({
|
||||
enabled: Boolean(client && readTarget),
|
||||
queryFn: async () => {
|
||||
if (!client || !readTarget) {
|
||||
return { file: null as ExplorerFile | null, error: "Host is not connected" };
|
||||
return {
|
||||
file: null as ExplorerFile | null,
|
||||
error: t("workspace.terminal.hostDisconnected"),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const file = await client.readFile(readTarget.cwd, readTarget.path);
|
||||
@@ -740,7 +746,7 @@ export function FilePane({
|
||||
return {
|
||||
file: null,
|
||||
imageAttachment: null,
|
||||
error: error instanceof Error ? error.message : "Failed to load file",
|
||||
error: error instanceof Error ? error.message : t("panels.file.failedToLoad"),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -19,6 +20,7 @@ function goBack(): void {
|
||||
|
||||
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const handleBack = useCallback(() => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
@@ -35,7 +37,7 @@ export function BackHeader({ title, titleAccessory, rightContent, onBack }: Back
|
||||
onPress={handleBack}
|
||||
style={styles.backButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back"
|
||||
accessibilityLabel={t("common.actions.back")}
|
||||
>
|
||||
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { PanelLeft } from "lucide-react-native";
|
||||
@@ -48,6 +49,7 @@ export function SidebarMenuToggle({
|
||||
nativeID = "menu-button",
|
||||
}: SidebarMenuToggleProps = {}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
|
||||
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
|
||||
@@ -68,7 +70,7 @@ export function SidebarMenuToggle({
|
||||
return (
|
||||
<HeaderToggleButton
|
||||
onPress={handlePress}
|
||||
tooltipLabel="Toggle sidebar"
|
||||
tooltipLabel={t("shell.menu.toggleSidebar")}
|
||||
tooltipKeys={toggleShortcutKeys}
|
||||
tooltipSide={tooltipSide}
|
||||
testID={testID}
|
||||
@@ -76,7 +78,7 @@ export function SidebarMenuToggle({
|
||||
style={style}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
|
||||
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
|
||||
accessibilityState={accessibilityState}
|
||||
>
|
||||
{isMobile ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Check, Copy } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { HighlightToken } from "@getpaseo/highlight";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -153,6 +154,7 @@ interface CopyButtonProps {
|
||||
const COPIED_RESET_MS = 1500;
|
||||
|
||||
const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -189,7 +191,7 @@ const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButt
|
||||
style={wrapperStyle}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={copied ? "Copied" : "Copy code"}
|
||||
accessibilityLabel={copied ? t("message.actions.copied") : t("message.actions.copyCode")}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export const PER_PROVIDER_LIMIT = 15;
|
||||
export const ALL_FILTER_VALUE = "__all__";
|
||||
@@ -97,11 +98,15 @@ export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string
|
||||
if (firstPromptPreview) {
|
||||
return firstPromptPreview;
|
||||
}
|
||||
return "Untitled session";
|
||||
return i18n.t("importSession.preview.untitledSession");
|
||||
}
|
||||
|
||||
export function getPromptPreview(entry: FetchRecentProviderSessionEntry): string {
|
||||
return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview";
|
||||
return (
|
||||
entry.lastPromptPreview?.trim() ||
|
||||
entry.firstPromptPreview?.trim() ||
|
||||
i18n.t("importSession.preview.noPrompt")
|
||||
);
|
||||
}
|
||||
|
||||
export interface EmptyStateInputs {
|
||||
@@ -132,10 +137,16 @@ export function computeEmptyState(input: EmptyStateInputs): {
|
||||
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
|
||||
if (isFilteredEmpty) {
|
||||
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
|
||||
return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` };
|
||||
return {
|
||||
showEmptyState,
|
||||
emptyStateTitle: i18n.t("importSession.empty.noProviderSessions", { provider: label }),
|
||||
};
|
||||
}
|
||||
if (input.totalAlreadyImportedCount > 0) {
|
||||
return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." };
|
||||
return {
|
||||
showEmptyState,
|
||||
emptyStateTitle: i18n.t("importSession.empty.alreadyImported"),
|
||||
};
|
||||
}
|
||||
return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
|
||||
return { showEmptyState, emptyStateTitle: i18n.t("importSession.empty.noRecent") };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
||||
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchRecentProviderSessionEntry,
|
||||
@@ -14,6 +15,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
aggregateSessionEntries,
|
||||
ALL_FILTER_VALUE,
|
||||
@@ -63,8 +65,10 @@ function buildSessionsQueriesConfig(args: {
|
||||
visible: boolean;
|
||||
client: RecentProviderSessionsClient | null;
|
||||
cwd: string | null | undefined;
|
||||
hostDisconnectedMessage?: string;
|
||||
}): SessionsQueryConfig[] {
|
||||
const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args;
|
||||
const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } =
|
||||
args;
|
||||
if (providersToFetch === null) return [];
|
||||
const enabled = visible && Boolean(client);
|
||||
return providersToFetch.map((provider) => ({
|
||||
@@ -72,7 +76,7 @@ function buildSessionsQueriesConfig(args: {
|
||||
enabled,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
return await client.fetchRecentProviderSessions({
|
||||
...(cwd ? { cwd } : {}),
|
||||
@@ -105,33 +109,36 @@ function SheetStatusMessages({
|
||||
importErrored,
|
||||
}: SheetStatusMessagesProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
if (!isClientReady) {
|
||||
return <Text style={styles.statusText}>Connect to a host to import sessions</Text>;
|
||||
return <Text style={styles.statusText}>{t("importSession.status.connectHost")}</Text>;
|
||||
}
|
||||
if (isSnapshotUnsupported) {
|
||||
return <Text style={styles.statusText}>Update the host to import sessions.</Text>;
|
||||
return <Text style={styles.statusText}>{t("importSession.status.updateHost")}</Text>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{hasNoImportableProviders ? (
|
||||
<Text style={styles.statusText}>No importable providers are enabled.</Text>
|
||||
<Text style={styles.statusText}>{t("importSession.status.noProviders")}</Text>
|
||||
) : null}
|
||||
{isLoadingSessions && !hasRows ? (
|
||||
<View style={styles.statusRow}>
|
||||
<LoadingSpinner color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.statusText}>Loading recent sessions...</Text>
|
||||
<Text style={styles.statusText}>{t("importSession.status.loading")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{allQueriesErrored ? (
|
||||
<Text style={styles.statusText}>Could not load recent sessions.</Text>
|
||||
<Text style={styles.statusText}>{t("importSession.status.failedAll")}</Text>
|
||||
) : null}
|
||||
{!allQueriesErrored && erroredProviderLabels.length > 0 ? (
|
||||
<Text style={styles.statusText}>
|
||||
Could not load sessions for {erroredProviderLabels.join(", ")}.
|
||||
{t("importSession.status.failedProviders", {
|
||||
providers: erroredProviderLabels.join(", "),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{importErrored ? (
|
||||
<Text style={styles.statusText}>Could not import selected session.</Text>
|
||||
<Text style={styles.statusText}>{t("importSession.status.failedImport")}</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
@@ -139,6 +146,7 @@ function SheetStatusMessages({
|
||||
|
||||
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
styles.refreshButton,
|
||||
@@ -150,7 +158,7 @@ function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPre
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={isRefreshing}
|
||||
accessibilityLabel="Refresh sessions"
|
||||
accessibilityLabel={t("importSession.actions.refresh")}
|
||||
accessibilityRole="button"
|
||||
testID="import-session-refresh"
|
||||
style={pressableStyle}
|
||||
@@ -192,6 +200,7 @@ function ImportSessionSheetRow({
|
||||
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const title = getSessionTitle(entry);
|
||||
const promptPreview = getPromptPreview(entry);
|
||||
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
|
||||
@@ -229,7 +238,9 @@ function ImportSessionSheetRow({
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.rowMeta}>{importing ? "Importing..." : lastActivity}</Text>
|
||||
<Text style={styles.rowMeta}>
|
||||
{importing ? t("importSession.row.importing") : lastActivity}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.rowPreview} numberOfLines={2}>
|
||||
{promptPreview}
|
||||
@@ -253,6 +264,7 @@ export function ImportSessionSheet({
|
||||
onImportedAgent,
|
||||
onImported,
|
||||
}: ImportSessionSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
@@ -284,8 +296,9 @@ export function ImportSessionSheet({
|
||||
visible,
|
||||
client,
|
||||
cwd,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
}),
|
||||
[providersToFetch, sessionsQueryRoot, visible, client, cwd],
|
||||
[providersToFetch, sessionsQueryRoot, visible, client, cwd, t],
|
||||
);
|
||||
|
||||
const queries = useQueries({ queries: queriesConfig });
|
||||
@@ -318,19 +331,20 @@ export function ImportSessionSheet({
|
||||
|
||||
const filterComboboxOptions = useMemo<ComboboxOption[]>(
|
||||
() => [
|
||||
{ id: ALL_FILTER_VALUE, label: "All providers" },
|
||||
{ id: ALL_FILTER_VALUE, label: t("importSession.filters.all") },
|
||||
...filterProviders.map((provider) => ({
|
||||
id: provider,
|
||||
label: providerLabelById.get(provider) ?? provider,
|
||||
})),
|
||||
],
|
||||
[filterProviders, providerLabelById],
|
||||
[filterProviders, providerLabelById, t],
|
||||
);
|
||||
|
||||
const selectedProviderLabel = useMemo(
|
||||
() =>
|
||||
filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ?? "All providers",
|
||||
[filterComboboxOptions, selectedProvider],
|
||||
filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ??
|
||||
t("importSession.filters.all"),
|
||||
[filterComboboxOptions, selectedProvider, t],
|
||||
);
|
||||
|
||||
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
|
||||
@@ -385,7 +399,7 @@ export function ImportSessionSheet({
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
if (!entry.cwd) {
|
||||
throw new Error("Session is missing a working directory");
|
||||
@@ -430,10 +444,10 @@ export function ImportSessionSheet({
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: "Import session",
|
||||
title: t("importSession.title"),
|
||||
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
|
||||
}),
|
||||
[isRefreshing, handleRefresh],
|
||||
[isRefreshing, handleRefresh, t],
|
||||
);
|
||||
|
||||
const isSnapshotUnsupported = !supportsSnapshot;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
@@ -9,9 +10,9 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
|
||||
|
||||
const SNAP_POINTS: string[] = ["70%", "92%"];
|
||||
const SHORTCUTS_HEADER: SheetHeader = { title: "Shortcuts" };
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
const { t } = useTranslation();
|
||||
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
|
||||
|
||||
@@ -23,10 +24,11 @@ export function KeyboardShortcutsDialog() {
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => setOpen(false), [setOpen]);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("settings.shortcuts.dialogTitle") }), [t]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={SHORTCUTS_HEADER}
|
||||
header={header}
|
||||
visible={open}
|
||||
onClose={handleClose}
|
||||
testID="keyboard-shortcuts-dialog"
|
||||
@@ -35,13 +37,15 @@ export function KeyboardShortcutsDialog() {
|
||||
<View testID="keyboard-shortcuts-dialog-content" style={styles.content}>
|
||||
{sections.map((section) => (
|
||||
<View key={section.title} style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
<Text style={styles.sectionTitle}>{t(section.titleKey)}</Text>
|
||||
<View style={styles.rows}>
|
||||
{section.rows.map((row) => (
|
||||
<View key={row.id} style={styles.row}>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowLabel}>{row.label}</Text>
|
||||
{row.note ? <Text style={styles.rowNote}>{row.note}</Text> : null}
|
||||
<Text style={styles.rowLabel}>{t(row.labelKey)}</Text>
|
||||
{row.note ? (
|
||||
<Text style={styles.rowNote}>{row.noteKey ? t(row.noteKey) : row.note}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Shortcut keys={row.keys} style={styles.rowShortcut} />
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { FolderPlus, Home, MessagesSquare, Plus, Search, Settings, X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type Dispatch,
|
||||
memo,
|
||||
@@ -104,6 +105,7 @@ interface SidebarSharedProps {
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
handleSettings: () => void;
|
||||
labels: SidebarLabels;
|
||||
renderHostOption: (input: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
@@ -112,6 +114,16 @@ interface SidebarSharedProps {
|
||||
}) => ReactElement;
|
||||
}
|
||||
|
||||
interface SidebarLabels {
|
||||
addProject: string;
|
||||
home: string;
|
||||
settings: string;
|
||||
switchHost: string;
|
||||
searchHosts: string;
|
||||
sessions: string;
|
||||
closeSidebar: string;
|
||||
}
|
||||
|
||||
interface MobileSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
insetsBottom: number;
|
||||
@@ -132,6 +144,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
void _selectedAgentId;
|
||||
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const isOpen = usePanelStore((state) =>
|
||||
@@ -146,10 +159,10 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
);
|
||||
const activeServerId = activeDaemon?.serverId ?? null;
|
||||
const activeHostLabel = useMemo(() => {
|
||||
if (!activeDaemon) return "No host";
|
||||
if (!activeDaemon) return t("sidebar.host.noHost");
|
||||
const trimmed = activeDaemon.label?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
|
||||
}, [activeDaemon]);
|
||||
}, [activeDaemon, t]);
|
||||
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
|
||||
const activeHostStatus = activeServerId
|
||||
? (activeHostSnapshot?.connectionStatus ?? "connecting")
|
||||
@@ -271,6 +284,19 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
[pathname],
|
||||
);
|
||||
|
||||
const labels = useMemo(
|
||||
(): SidebarLabels => ({
|
||||
addProject: t("sidebar.actions.addProject"),
|
||||
home: t("sidebar.actions.home"),
|
||||
settings: t("sidebar.actions.settings"),
|
||||
switchHost: t("sidebar.host.switchTitle"),
|
||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||
sessions: t("sidebar.sections.sessions"),
|
||||
closeSidebar: t("sidebar.actions.closeSidebar"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const sharedProps = {
|
||||
theme,
|
||||
activeServerId,
|
||||
@@ -291,6 +317,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
labels,
|
||||
};
|
||||
|
||||
if (isCompactLayout) {
|
||||
@@ -425,12 +452,14 @@ function FooterIconButton({
|
||||
|
||||
function AddProjectTooltipContent({
|
||||
newAgentKeys,
|
||||
label,
|
||||
}: {
|
||||
newAgentKeys: ReturnType<typeof useShortcutKeys>;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
|
||||
</View>
|
||||
);
|
||||
@@ -465,6 +494,7 @@ function SidebarFooter({
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
labels,
|
||||
}: {
|
||||
theme: SidebarTheme;
|
||||
activeServerId: string | null;
|
||||
@@ -479,6 +509,13 @@ function SidebarFooter({
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
handleSettings: () => void;
|
||||
labels: {
|
||||
addProject: string;
|
||||
home: string;
|
||||
settings: string;
|
||||
switchHost: string;
|
||||
searchHosts: string;
|
||||
};
|
||||
}) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
return (
|
||||
@@ -498,26 +535,26 @@ function SidebarFooter({
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
accessibilityLabel="Add project"
|
||||
accessibilityLabel={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
theme={theme}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<AddProjectTooltipContent newAgentKeys={newAgentKeys} />
|
||||
<AddProjectTooltipContent newAgentKeys={newAgentKeys} label={labels.addProject} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<FooterIconButton
|
||||
onPress={handleHome}
|
||||
testID="sidebar-home"
|
||||
accessibilityLabel="Home"
|
||||
accessibilityLabel={labels.home}
|
||||
icon={Home}
|
||||
theme={theme}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityLabel={labels.settings}
|
||||
icon={Settings}
|
||||
theme={theme}
|
||||
/>
|
||||
@@ -528,8 +565,8 @@ function SidebarFooter({
|
||||
onSelect={handleHostSelect}
|
||||
renderOption={renderHostOption}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
title={labels.switchHost}
|
||||
searchPlaceholder={labels.searchHosts}
|
||||
desktopMinWidth={280}
|
||||
open={isHostPickerOpen}
|
||||
onOpenChange={setIsHostPickerOpen}
|
||||
@@ -563,6 +600,7 @@ function MobileSidebar({
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
labels,
|
||||
insetsTop,
|
||||
insetsBottom,
|
||||
isOpen,
|
||||
@@ -748,7 +786,7 @@ function MobileSidebar({
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
label={labels.sessions}
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
@@ -765,7 +803,7 @@ function MobileSidebar({
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close sidebar"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
@@ -810,6 +848,7 @@ function MobileSidebar({
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleHome={handleHome}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
@@ -842,6 +881,7 @@ function DesktopSidebar({
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
labels,
|
||||
insetsTop,
|
||||
isOpen,
|
||||
handleViewMore,
|
||||
@@ -919,7 +959,7 @@ function DesktopSidebar({
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
label={labels.sessions}
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
@@ -963,6 +1003,7 @@ function DesktopSidebar({
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleHome={handleHome}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
/>
|
||||
|
||||
{/* Resize handle - absolutely positioned over right border */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
|
||||
describe("getCompactionMarkerLabel", () => {
|
||||
@@ -16,4 +17,13 @@ describe("getCompactionMarkerLabel", () => {
|
||||
);
|
||||
expect(getCompactionMarkerLabel({ status: "completed" })).toBe("Context compacted");
|
||||
});
|
||||
|
||||
it("renders labels in the active app language", async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
try {
|
||||
expect(getCompactionMarkerLabel({ status: "loading" })).toBe("正在压缩...");
|
||||
} finally {
|
||||
await i18n.changeLanguage("en");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export interface CompactionMarkerLabelInput {
|
||||
status: "loading" | "completed";
|
||||
trigger?: "auto" | "manual";
|
||||
@@ -9,9 +11,13 @@ export function getCompactionMarkerLabel({
|
||||
trigger,
|
||||
preTokens,
|
||||
}: CompactionMarkerLabelInput): string {
|
||||
if (status === "loading") return "Compacting...";
|
||||
if (trigger === "auto") return "Context automatically compacted";
|
||||
if (trigger === "manual") return "Context manually compacted";
|
||||
if (preTokens) return `Context compacted (${Math.round(preTokens / 1000)}K tokens)`;
|
||||
return "Context compacted";
|
||||
if (status === "loading") return i18n.t("message.compaction.loading");
|
||||
if (trigger === "auto") return i18n.t("message.compaction.auto");
|
||||
if (trigger === "manual") return i18n.t("message.compaction.manual");
|
||||
if (preTokens) {
|
||||
return i18n.t("message.compaction.withTokens", {
|
||||
tokens: Math.round(preTokens / 1000),
|
||||
});
|
||||
}
|
||||
return i18n.t("message.compaction.completed");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ViewStyle,
|
||||
type TextStyle,
|
||||
} from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import * as React from "react";
|
||||
@@ -448,18 +449,23 @@ function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAtta
|
||||
return <Image source={imageSource} style={userMessageStylesheet.imageThumbnail} />;
|
||||
}
|
||||
|
||||
function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
|
||||
function getUserMessageAttachmentLabel(
|
||||
attachment: AgentAttachment,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): string {
|
||||
switch (attachment.type) {
|
||||
case "review": {
|
||||
const count = attachment.comments.length;
|
||||
return count === 1 ? "Review · 1 comment" : `Review · ${count} comments`;
|
||||
return count === 1
|
||||
? t("message.attachments.reviewOne")
|
||||
: t("message.attachments.reviewMany", { count });
|
||||
}
|
||||
case "github_pr":
|
||||
return `PR #${attachment.number}`;
|
||||
case "github_issue":
|
||||
return `Issue #${attachment.number}`;
|
||||
case "text":
|
||||
return attachment.title ?? "Text attachment";
|
||||
return attachment.title ?? t("message.attachments.textAttachment");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -480,6 +486,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
disableOuterSpacing,
|
||||
}: UserMessageProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const hasText = message.trim().length > 0;
|
||||
@@ -562,7 +569,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
style={userMessageStylesheet.structuredAttachmentPill}
|
||||
>
|
||||
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}>
|
||||
{getUserMessageAttachmentLabel(attachment)}
|
||||
{getUserMessageAttachmentLabel(attachment, t)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -588,7 +595,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
<TurnCopyButton
|
||||
getContent={getMessageContent}
|
||||
containerStyle={userMessageStylesheet.copyButton}
|
||||
accessibilityLabel="Copy message"
|
||||
accessibilityLabel={t("message.actions.copyMessage")}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -862,6 +869,7 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
|
||||
const handleImageError = useCallback(() => {
|
||||
setLoadState({ status: "error" });
|
||||
}, []);
|
||||
const { t } = useTranslation();
|
||||
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
assistantMessageStylesheet.imageSurface,
|
||||
@@ -887,7 +895,9 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
|
||||
<View style={stateSurfaceStyle}>
|
||||
{loadState.status === "loading" ? <ActivityIndicator size="small" /> : null}
|
||||
{loadState.status === "error" ? (
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>Image unavailable</Text>
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>
|
||||
{t("message.attachments.imageUnavailable")}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
@@ -924,6 +934,7 @@ function AssistantMarkdownImage({
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolution = useMemo(
|
||||
() => resolveAssistantImageSource({ source, workspaceRoot }),
|
||||
[source, workspaceRoot],
|
||||
@@ -953,7 +964,7 @@ function AssistantMarkdownImage({
|
||||
|
||||
const file = await client.readFile(resolution.cwd, resolution.path);
|
||||
if (file.kind !== "image") {
|
||||
throw new Error("Image preview unavailable.");
|
||||
throw new Error(t("message.attachments.imagePreviewUnavailable"));
|
||||
}
|
||||
|
||||
return await persistAttachmentFromBytes({
|
||||
@@ -1026,7 +1037,11 @@ function AssistantMarkdownImage({
|
||||
);
|
||||
}
|
||||
|
||||
const errorText = resolveAssistantImageErrorText(query.error, dataImageQuery.error);
|
||||
const errorText = resolveAssistantImageErrorText(
|
||||
query.error,
|
||||
dataImageQuery.error,
|
||||
t("message.attachments.imagePreviewLoadFailed"),
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={stateFrameStyle}>
|
||||
@@ -1035,10 +1050,14 @@ function AssistantMarkdownImage({
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAssistantImageErrorText(fileError: unknown, dataError: unknown): string {
|
||||
function resolveAssistantImageErrorText(
|
||||
fileError: unknown,
|
||||
dataError: unknown,
|
||||
fallbackText: string,
|
||||
): string {
|
||||
if (fileError instanceof Error) return fileError.message;
|
||||
if (dataError instanceof Error) return dataError.message;
|
||||
return "Unable to load image preview.";
|
||||
return fallbackText;
|
||||
}
|
||||
|
||||
function getInlineCodeAutoLinkUrl(
|
||||
@@ -1144,6 +1163,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
accessibilityLabel,
|
||||
copiedAccessibilityLabel,
|
||||
}: TurnCopyButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -1185,7 +1205,9 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn")
|
||||
copied
|
||||
? (copiedAccessibilityLabel ?? t("message.actions.copied"))
|
||||
: (accessibilityLabel ?? t("message.actions.copyTurn"))
|
||||
}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
@@ -1966,6 +1988,7 @@ export const SpeakMessage = memo(function SpeakMessage({
|
||||
timestamp: _timestamp,
|
||||
disableOuterSpacing,
|
||||
}: SpeakMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
@@ -1979,7 +2002,7 @@ export const SpeakMessage = memo(function SpeakMessage({
|
||||
<View testID="speak-message" style={containerStyle}>
|
||||
<View style={speakMessageStylesheet.header}>
|
||||
<ThemedMicVocal size={12} uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
|
||||
<Text style={speakMessageStylesheet.headerLabel}>{t("message.speak.header")}</Text>
|
||||
</View>
|
||||
<Text style={speakMessageStylesheet.text}>{message}</Text>
|
||||
</View>
|
||||
@@ -2080,6 +2103,7 @@ export const ActivityLog = memo(function ActivityLog({
|
||||
onArtifactClick,
|
||||
disableOuterSpacing,
|
||||
}: ActivityLogProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
@@ -2149,7 +2173,9 @@ export const ActivityLog = memo(function ActivityLog({
|
||||
</Text>
|
||||
{metadata && (
|
||||
<View style={activityLogStylesheet.detailsRow}>
|
||||
<Text style={activityLogStylesheet.detailsText}>Details</Text>
|
||||
<Text style={activityLogStylesheet.detailsText}>
|
||||
{t("message.activity.details")}
|
||||
</Text>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={12} color="#71717a" />
|
||||
) : (
|
||||
@@ -2306,6 +2332,7 @@ export const TodoListCard = memo(function TodoListCard({
|
||||
items,
|
||||
disableOuterSpacing,
|
||||
}: TodoListCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]);
|
||||
@@ -2319,7 +2346,7 @@ export const TodoListCard = memo(function TodoListCard({
|
||||
<View style={todoListCardStylesheet.detailsWrapper}>
|
||||
<View style={todoListCardStylesheet.list}>
|
||||
{items.length === 0 ? (
|
||||
<Text style={todoListCardStylesheet.emptyText}>No tasks yet.</Text>
|
||||
<Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<TodoListItemRow key={item.text} text={item.text} completed={item.completed} />
|
||||
@@ -2328,11 +2355,11 @@ export const TodoListCard = memo(function TodoListCard({
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}, [items]);
|
||||
}, [items, t]);
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
label="Tasks"
|
||||
label={t("message.todo.title")}
|
||||
secondaryLabel={nextTask}
|
||||
icon={CheckSquare}
|
||||
isExpanded={isExpanded}
|
||||
@@ -2475,6 +2502,7 @@ function ExpandableBadgeLabelRow({
|
||||
onOpenFileHoverIn,
|
||||
onOpenFileHoverOut,
|
||||
}: ExpandableBadgeLabelRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View
|
||||
style={expandableBadgeStylesheet.labelRow}
|
||||
@@ -2499,7 +2527,7 @@ function ExpandableBadgeLabelRow({
|
||||
onHoverIn={onOpenFileHoverIn}
|
||||
onHoverOut={onOpenFileHoverOut}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open file"
|
||||
accessibilityLabel={t("message.actions.openFile")}
|
||||
testID="tool-call-open-file"
|
||||
style={expandableBadgeStylesheet.openFileButton}
|
||||
hitSlop={6}
|
||||
@@ -3169,7 +3197,6 @@ export const ToolCall = memo(function ToolCall({
|
||||
if (presentation.isPlan && effectiveDetail?.type === "plan") {
|
||||
return (
|
||||
<PlanCard
|
||||
title="Plan"
|
||||
text={effectiveDetail.text}
|
||||
testID="timeline-plan-card"
|
||||
disableOuterSpacing={disableOuterSpacing}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Text, TextInput, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -12,7 +13,6 @@ import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adapt
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const PAIR_LINK_HEADER: SheetHeader = { title: "Paste pairing link" };
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
helper: {
|
||||
@@ -61,6 +61,7 @@ export interface PairLinkModalProps {
|
||||
|
||||
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
@@ -98,11 +99,11 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
if (isSaving) return;
|
||||
const raw = offerUrlRef.current.trim();
|
||||
if (!raw) {
|
||||
setErrorMessage("Paste a pairing link (…/#offer=...)");
|
||||
setErrorMessage(t("pairing.link.errors.required"));
|
||||
return;
|
||||
}
|
||||
if (!raw.includes("#offer=")) {
|
||||
setErrorMessage("Link must include #offer=...");
|
||||
setErrorMessage(t("pairing.link.errors.missingOffer"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -111,15 +112,15 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
const idx = raw.indexOf("#offer=");
|
||||
const encoded = raw.slice(idx + "#offer=".length).trim();
|
||||
if (!encoded) {
|
||||
throw new Error("Offer payload is empty");
|
||||
throw new Error(t("pairing.link.errors.emptyOffer"));
|
||||
}
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
return ConnectionOfferSchema.parse(payload);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid pairing link";
|
||||
const message = error instanceof Error ? error.message : t("pairing.link.errors.invalid");
|
||||
setErrorMessage(message);
|
||||
if (!isMobile) {
|
||||
Alert.alert("Pairing failed", message);
|
||||
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -150,15 +151,16 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
|
||||
setErrorMessage(message);
|
||||
if (!isMobile) {
|
||||
Alert.alert("Pairing failed", message);
|
||||
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]);
|
||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, t, upsertDaemonFromOfferUrl]);
|
||||
|
||||
const handleChangeOfferUrl = useCallback((next: string) => {
|
||||
offerUrlRef.current = next;
|
||||
@@ -168,22 +170,24 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
void handleSave();
|
||||
}, [handleSave]);
|
||||
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.link.title") }), [t]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={PAIR_LINK_HEADER}
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
testID="pair-link-modal"
|
||||
>
|
||||
<Text style={styles.helper}>Paste the pairing link from your server.</Text>
|
||||
<Text style={styles.helper}>{t("pairing.link.helper")}</Text>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Pairing link</Text>
|
||||
<Text style={styles.label}>{t("pairing.link.label")}</Text>
|
||||
<AdaptiveTextInput
|
||||
ref={inputRef}
|
||||
testID="pair-link-input"
|
||||
nativeID="pair-link-input"
|
||||
accessibilityLabel="pair-link-input"
|
||||
accessibilityLabel={t("pairing.link.label")}
|
||||
onChangeText={handleChangeOfferUrl}
|
||||
placeholder="https://app.paseo.sh/#offer=..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
@@ -204,9 +208,9 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
disabled={isSaving}
|
||||
testID="pair-link-cancel"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel"
|
||||
accessibilityLabel={t("pairing.link.actions.cancel")}
|
||||
>
|
||||
Cancel
|
||||
{t("pairing.link.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
style={FLEX_ONE_STYLE}
|
||||
@@ -215,10 +219,10 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
disabled={isSaving}
|
||||
testID="pair-link-submit"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Pair"
|
||||
accessibilityLabel={t("pairing.link.actions.pair")}
|
||||
leftIcon={pairIcon}
|
||||
>
|
||||
{isSaving ? "Pairing..." : "Pair"}
|
||||
{isSaving ? t("pairing.link.actions.pairing") : t("pairing.link.actions.pair")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import Markdown, { type ASTNode } from "react-native-markdown-display";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
|
||||
@@ -194,7 +195,7 @@ function createPlanMarkdownRules() {
|
||||
}
|
||||
|
||||
export function PlanCard({
|
||||
title = "Plan",
|
||||
title,
|
||||
description,
|
||||
text,
|
||||
footer,
|
||||
@@ -209,8 +210,10 @@ export function PlanCard({
|
||||
testID?: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const markdownStyles = createMarkdownStyles(theme);
|
||||
const markdownRules = createPlanMarkdownRules();
|
||||
const resolvedTitle = title ?? t("agentStream.permission.plan");
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
@@ -234,7 +237,7 @@ export function PlanCard({
|
||||
|
||||
return (
|
||||
<View testID={testID} style={containerStyle}>
|
||||
<Text style={titleStyle}>{title}</Text>
|
||||
<Text style={titleStyle}>{resolvedTitle}</Text>
|
||||
{description ? <Text style={descriptionStyle}>{description}</Text> : null}
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
{text}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||
@@ -60,6 +61,7 @@ function PathRow({ path, active, onSelect }: PathRowProps) {
|
||||
|
||||
export function ProjectPickerModal() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const serverId = useActiveServerId();
|
||||
|
||||
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
||||
@@ -235,7 +237,7 @@ export function ProjectPickerModal() {
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChangeText={handleChangeQuery}
|
||||
placeholder="Type a directory path..."
|
||||
placeholder={t("projectPicker.placeholder")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={inputStyle}
|
||||
autoCapitalize="none"
|
||||
@@ -253,9 +255,9 @@ export function ProjectPickerModal() {
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{isSubmitting ? <Text style={emptyTextStyle}>Opening project...</Text> : null}
|
||||
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
|
||||
{!isSubmitting && options.length === 0 && !query.trim() ? (
|
||||
<Text style={emptyTextStyle}>Start typing a path</Text>
|
||||
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
|
||||
) : null}
|
||||
{!isSubmitting && !(options.length === 0 && !query.trim()) ? (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
@@ -48,6 +49,10 @@ interface CatalogRowProps {
|
||||
}
|
||||
|
||||
function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionLabel = installing
|
||||
? t("providerCatalog.actions.adding")
|
||||
: t("providerCatalog.actions.add");
|
||||
const handleInstall = useCallback(() => {
|
||||
onInstall(entry);
|
||||
}, [entry, onInstall]);
|
||||
@@ -84,12 +89,14 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`${entry.title} install instructions`}
|
||||
accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
|
||||
provider: entry.title,
|
||||
})}
|
||||
onPress={handleOpenInstallLink}
|
||||
style={styles.installLink}
|
||||
>
|
||||
<Text style={styles.installLinkText} numberOfLines={1}>
|
||||
Install instructions
|
||||
{t("providerCatalog.actions.installInstructions")}
|
||||
</Text>
|
||||
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
|
||||
</Pressable>
|
||||
@@ -103,7 +110,7 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
|
||||
style={styles.actionButton}
|
||||
testID={`install-provider-${entry.id}`}
|
||||
>
|
||||
{installing ? "Adding" : "Add"}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -114,6 +121,7 @@ export function ProviderCatalogList({
|
||||
installingProviderId,
|
||||
onInstall,
|
||||
}: ProviderCatalogListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { entries: catalogEntries } = useAcpProviderCatalog();
|
||||
const { entries: providerEntries } = useProvidersSnapshot(serverId);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -139,10 +147,10 @@ export function ProviderCatalogList({
|
||||
</View>
|
||||
<AdaptiveTextInput
|
||||
testID="provider-catalog-search"
|
||||
accessibilityLabel="Search providers"
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder="Search providers"
|
||||
accessibilityLabel={t("providerCatalog.search")}
|
||||
placeholder={t("providerCatalog.search")}
|
||||
style={styles.searchInput}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -151,9 +159,7 @@ export function ProviderCatalogList({
|
||||
|
||||
{availableEntries.length === 0 ? (
|
||||
<View style={styles.stateBox}>
|
||||
<Text style={styles.stateText}>
|
||||
{search.trim().length > 0 ? "No providers found" : "All providers are installed"}
|
||||
</Text>
|
||||
<Text style={styles.stateText}>{t("providerCatalog.noProviders")}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.list}>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
@@ -79,6 +81,7 @@ function CustomModelRow({
|
||||
deleting: boolean;
|
||||
onDelete: (modelId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]);
|
||||
const deleteButtonStyle = useCallback(
|
||||
@@ -110,7 +113,7 @@ function CustomModelRow({
|
||||
hitSlop={8}
|
||||
style={deleteButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Remove ${model.id}`}
|
||||
accessibilityLabel={t("settings.providers.models.removeModel", { id: model.id })}
|
||||
>
|
||||
<Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />
|
||||
</Pressable>
|
||||
@@ -148,6 +151,7 @@ function AddCustomModelSubSheet({
|
||||
onClose: () => void;
|
||||
refresh: (providers?: AgentProvider[]) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const [input, setInput] = useState("");
|
||||
@@ -182,12 +186,15 @@ function AddCustomModelSubSheet({
|
||||
.then(() => refresh([provider]))
|
||||
.then(() => onClose())
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to save model");
|
||||
setError(err instanceof Error ? err.message : t("settings.providers.models.failedToSave"));
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
}, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, trimmed]);
|
||||
}, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, t, trimmed]);
|
||||
|
||||
const header = useMemo<SheetHeader>(() => ({ title: "Add custom model" }), []);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("settings.providers.models.addCustomTitle") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
@@ -199,14 +206,14 @@ function AddCustomModelSubSheet({
|
||||
testID="add-custom-model-sheet"
|
||||
>
|
||||
<View style={sheetStyles.formGroup}>
|
||||
<Text style={sheetStyles.formLabel}>Model ID</Text>
|
||||
<Text style={sheetStyles.formLabel}>{t("settings.providers.models.modelId")}</Text>
|
||||
<AdaptiveTextInput
|
||||
initialValue={input}
|
||||
resetKey={`add-custom-${visible}`}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
onSubmitEditing={handleAdd}
|
||||
placeholder="e.g. openai/gpt-5"
|
||||
placeholder={t("settings.providers.models.modelIdPlaceholder")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -217,10 +224,10 @@ function AddCustomModelSubSheet({
|
||||
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
|
||||
<View style={sheetStyles.formActions}>
|
||||
<Button variant="secondary" size="sm" onPress={onClose} disabled={saving}>
|
||||
Cancel
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onPress={handleAdd} disabled={!canAdd || saving}>
|
||||
{saving ? "Adding…" : "Add"}
|
||||
{saving ? t("settings.providers.models.adding") : t("settings.providers.models.add")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -239,6 +246,7 @@ function DiagnosticSubSheet({
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||
@@ -251,11 +259,13 @@ function DiagnosticSubSheet({
|
||||
const result = await client.getProviderDiagnostic(provider);
|
||||
setDiagnostic(result.diagnostic);
|
||||
} catch (err) {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
||||
setDiagnostic(
|
||||
err instanceof Error ? err.message : t("settings.providers.diagnostic.failedToFetch"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, provider]);
|
||||
}, [client, provider, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -280,7 +290,7 @@ function DiagnosticSubSheet({
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: "Diagnostic",
|
||||
title: t("settings.providers.diagnostic.title"),
|
||||
actions: (
|
||||
<Pressable
|
||||
onPress={handleRefreshPress}
|
||||
@@ -288,7 +298,11 @@ function DiagnosticSubSheet({
|
||||
hitSlop={8}
|
||||
style={refreshButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={loading ? "Refreshing diagnostic" : "Refresh diagnostic"}
|
||||
accessibilityLabel={
|
||||
loading
|
||||
? t("settings.providers.diagnostic.refreshingAccessibility")
|
||||
: t("settings.providers.diagnostic.refreshAccessibility")
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
@@ -302,6 +316,7 @@ function DiagnosticSubSheet({
|
||||
handleRefreshPress,
|
||||
loading,
|
||||
refreshButtonStyle,
|
||||
t,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.sm,
|
||||
],
|
||||
@@ -312,7 +327,7 @@ function DiagnosticSubSheet({
|
||||
body = (
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>Running diagnostic…</Text>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (diagnostic) {
|
||||
@@ -328,7 +343,7 @@ function DiagnosticSubSheet({
|
||||
} else {
|
||||
body = (
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<Text style={sheetStyles.mutedText}>No diagnostic available</Text>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -366,6 +381,7 @@ interface ProviderSheetFooterInput {
|
||||
fetchedAtLabel: string | null;
|
||||
isCompact: boolean;
|
||||
modelsRefreshing: boolean;
|
||||
t: TFunction;
|
||||
onOpenAddSheet: () => void;
|
||||
onOpenDiagSheet: () => void;
|
||||
onRefreshModels: () => void;
|
||||
@@ -375,6 +391,7 @@ function renderProviderSheetFooter({
|
||||
fetchedAtLabel,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
t,
|
||||
onOpenAddSheet,
|
||||
onOpenDiagSheet,
|
||||
onRefreshModels,
|
||||
@@ -388,7 +405,7 @@ function renderProviderSheetFooter({
|
||||
<View style={contentStyle}>
|
||||
{fetchedAtLabel || !isCompact ? (
|
||||
<Text style={metaStyle} numberOfLines={1}>
|
||||
{fetchedAtLabel ? `Updated ${fetchedAtLabel}` : ""}
|
||||
{fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={actionsStyle}>
|
||||
@@ -399,7 +416,7 @@ function renderProviderSheetFooter({
|
||||
onPress={onOpenAddSheet}
|
||||
style={buttonStyle}
|
||||
>
|
||||
Add model
|
||||
{t("settings.providers.models.addModel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -408,7 +425,7 @@ function renderProviderSheetFooter({
|
||||
onPress={onOpenDiagSheet}
|
||||
style={buttonStyle}
|
||||
>
|
||||
Diagnostic
|
||||
{t("settings.providers.diagnostic.button")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -418,7 +435,9 @@ function renderProviderSheetFooter({
|
||||
disabled={modelsRefreshing}
|
||||
style={buttonStyle}
|
||||
>
|
||||
{modelsRefreshing ? "Refreshing…" : "Refresh"}
|
||||
{modelsRefreshing
|
||||
? t("settings.providers.diagnostic.refreshing")
|
||||
: t("settings.providers.diagnostic.refresh")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -426,6 +445,7 @@ function renderProviderSheetFooter({
|
||||
}
|
||||
|
||||
function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
discoveredCount,
|
||||
additionalCount,
|
||||
@@ -445,7 +465,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>Loading models…</Text>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -455,7 +475,9 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
|
||||
<Button variant="default" size="sm" onPress={onRefresh} disabled={modelsRefreshing}>
|
||||
{modelsRefreshing ? "Retrying…" : "Retry"}
|
||||
{modelsRefreshing
|
||||
? t("settings.providers.models.retrying")
|
||||
: t("settings.providers.models.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -463,14 +485,14 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
if (filteredDiscovered.length === 0 && filteredCustom.length === 0 && searchActive) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<Text style={sheetStyles.mutedText}>No models match your search</Text>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noSearchMatches")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (discoveredCount === 0 && additionalCount === 0) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<Text style={sheetStyles.mutedText}>No models detected</Text>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noneDetected")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -478,7 +500,10 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
<>
|
||||
{filteredDiscovered.length > 0 ? (
|
||||
<View style={sheetStyles.section}>
|
||||
<SectionHeader title="Discovered" count={filteredDiscovered.length} />
|
||||
<SectionHeader
|
||||
title={t("settings.providers.models.discovered")}
|
||||
count={filteredDiscovered.length}
|
||||
/>
|
||||
<View style={settingsStyles.card}>
|
||||
{filteredDiscovered.map((model) => (
|
||||
<DiscoveredModelRow key={model.id} model={model} />
|
||||
@@ -488,7 +513,10 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
) : null}
|
||||
{filteredCustom.length > 0 ? (
|
||||
<View style={sheetStyles.section}>
|
||||
<SectionHeader title="Custom models" count={filteredCustom.length} />
|
||||
<SectionHeader
|
||||
title={t("settings.providers.models.custom")}
|
||||
count={filteredCustom.length}
|
||||
/>
|
||||
<View style={settingsStyles.card}>
|
||||
{filteredCustom.map((model) => (
|
||||
<CustomModelRow
|
||||
@@ -511,6 +539,7 @@ export function ProviderDiagnosticSheet({
|
||||
onClose,
|
||||
serverId,
|
||||
}: ProviderDiagnosticSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||
@@ -531,7 +560,9 @@ export function ProviderDiagnosticSheet({
|
||||
);
|
||||
const providerSnapshotRefreshing = providerEntry?.status === "loading";
|
||||
const providerErrorMessage =
|
||||
providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null;
|
||||
providerEntry?.status === "error"
|
||||
? (providerEntry.error ?? t("settings.providers.diagnostic.unknownError"))
|
||||
: null;
|
||||
const modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
|
||||
|
||||
const stableDiscoveredRef = useRef<AgentModelDefinition[]>([]);
|
||||
@@ -546,7 +577,7 @@ export function ProviderDiagnosticSheet({
|
||||
const [clockTick, setClockTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const id = setInterval(() => setClockTick((t) => t + 1), 10_000);
|
||||
const id = setInterval(() => setClockTick((tick) => tick + 1), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, [visible]);
|
||||
const fetchedAtLabel = useMemo(() => {
|
||||
@@ -605,11 +636,11 @@ export function ProviderDiagnosticSheet({
|
||||
title: providerLabel,
|
||||
search: {
|
||||
onChange: setQuery,
|
||||
placeholder: "Search models",
|
||||
placeholder: t("settings.providers.models.searchPlaceholder"),
|
||||
testID: "provider-settings-search",
|
||||
},
|
||||
}),
|
||||
[providerLabel],
|
||||
[providerLabel, t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -623,6 +654,7 @@ export function ProviderDiagnosticSheet({
|
||||
fetchedAtLabel,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
t,
|
||||
onOpenAddSheet: handleOpenAddSheet,
|
||||
onOpenDiagSheet: handleOpenDiagSheet,
|
||||
onRefreshModels: handleRefreshModels,
|
||||
|
||||
@@ -138,6 +138,9 @@ export function shouldSubmitEmptyOnDismiss(questions: QuestionFormQuestion[]): b
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDismissLabel(questions: QuestionFormQuestion[]): string {
|
||||
return questions.find((question) => question.dismissLabel)?.dismissLabel ?? "Dismiss";
|
||||
export function resolveDismissLabel(
|
||||
questions: QuestionFormQuestion[],
|
||||
fallbackLabel = "Dismiss",
|
||||
): string {
|
||||
return questions.find((question) => question.dismissLabel)?.dismissLabel ?? fallbackLabel;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -33,9 +34,17 @@ interface QuestionFormCardProps {
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
function getQuestionInputPlaceholder(question: QuestionFormQuestion): string {
|
||||
function getQuestionInputPlaceholder({
|
||||
question,
|
||||
answerPlaceholder,
|
||||
otherPlaceholder,
|
||||
}: {
|
||||
question: QuestionFormQuestion;
|
||||
answerPlaceholder: string;
|
||||
otherPlaceholder: string;
|
||||
}): string {
|
||||
return (
|
||||
question.placeholder ?? (question.options.length === 0 ? "Type your answer..." : "Other...")
|
||||
question.placeholder ?? (question.options.length === 0 ? answerPlaceholder : otherPlaceholder)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -239,6 +248,7 @@ function QuestionOtherInput({
|
||||
|
||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const questions = useMemo(
|
||||
() => parseQuestionFormQuestions(permission.request.input),
|
||||
@@ -367,7 +377,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
);
|
||||
|
||||
const primaryDisabled = isResponding || (isLastQuestion ? !allAnswered : !activeQuestionAnswered);
|
||||
const primaryActionLabel = isLastQuestion ? "Submit" : "Next";
|
||||
const primaryActionLabel = isLastQuestion
|
||||
? t("message.question.submit")
|
||||
: t("message.question.next");
|
||||
const submitButtonStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.actionButton,
|
||||
@@ -417,7 +429,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
return null;
|
||||
}
|
||||
|
||||
const dismissLabel = resolveDismissLabel(questions);
|
||||
const dismissLabel = resolveDismissLabel(questions, t("common.actions.dismiss"));
|
||||
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
|
||||
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
|
||||
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
|
||||
@@ -470,7 +482,11 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
qIndex={resolvedActiveQuestionIndex}
|
||||
accessibilityLabel={activeQuestion.question}
|
||||
value={otherText}
|
||||
placeholder={getQuestionInputPlaceholder(activeQuestion)}
|
||||
placeholder={getQuestionInputPlaceholder({
|
||||
question: activeQuestion,
|
||||
answerPlaceholder: t("message.question.answerPlaceholder"),
|
||||
otherPlaceholder: t("message.question.otherPlaceholder"),
|
||||
})}
|
||||
isResponding={isResponding}
|
||||
onChange={setOtherText}
|
||||
onSubmit={handlePrimaryAction}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
|
||||
export function QuittingOverlay() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const [quitting, setQuitting] = useState(false);
|
||||
|
||||
@@ -37,8 +39,8 @@ export function QuittingOverlay() {
|
||||
return (
|
||||
<View style={styles.overlay}>
|
||||
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.title}>Quitting Paseo…</Text>
|
||||
<Text style={styles.detail}>Stopping the local daemon.</Text>
|
||||
<Text style={styles.title}>{t("desktop.quitting.title")}</Text>
|
||||
<Text style={styles.detail}>{t("desktop.quitting.detail")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, Pressable, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, MicOff, Square } from "lucide-react-native";
|
||||
@@ -23,6 +24,7 @@ export function RealtimeVoiceOverlay({
|
||||
onStop,
|
||||
}: RealtimeVoiceOverlayProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const { volume, isSpeaking } = useVoiceTelemetry();
|
||||
const muteButtonStyle = useMemo(
|
||||
() => [
|
||||
@@ -53,7 +55,9 @@ export function RealtimeVoiceOverlay({
|
||||
onPress={onToggleMute}
|
||||
disabled={isSwitching}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isMuted ? "Unmute realtime voice" : "Mute realtime voice"}
|
||||
accessibilityLabel={
|
||||
isMuted ? t("realtimeVoice.actions.unmute") : t("realtimeVoice.actions.mute")
|
||||
}
|
||||
style={muteButtonStyle}
|
||||
>
|
||||
{isMuted ? (
|
||||
@@ -67,7 +71,7 @@ export function RealtimeVoiceOverlay({
|
||||
onPress={onStop}
|
||||
disabled={isSwitching}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Stop realtime voice and interrupt turn"
|
||||
accessibilityLabel={t("realtimeVoice.actions.stop")}
|
||||
style={stopButtonStyle}
|
||||
>
|
||||
{isSwitching ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Text, TextInput, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
@@ -27,13 +28,14 @@ export function AdaptiveRenameModal({
|
||||
title,
|
||||
initialValue,
|
||||
placeholder,
|
||||
submitLabel = "Rename",
|
||||
submitLabel,
|
||||
onClose,
|
||||
onSubmit,
|
||||
validate,
|
||||
maxLength,
|
||||
testID,
|
||||
}: AdaptiveRenameModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState(initialValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
@@ -64,10 +66,10 @@ export function AdaptiveRenameModal({
|
||||
|
||||
const computeError = useCallback(
|
||||
(value: string): string | null => {
|
||||
if (!value.trim()) return "Name is required";
|
||||
if (!value.trim()) return t("common.errors.nameRequired");
|
||||
return validate ? validate(value) : null;
|
||||
},
|
||||
[validate],
|
||||
[validate, t],
|
||||
);
|
||||
|
||||
const handleChange = useCallback((value: string) => {
|
||||
@@ -91,10 +93,11 @@ export function AdaptiveRenameModal({
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setIsPending(false);
|
||||
const message = err instanceof Error && err.message ? err.message : "Unable to save";
|
||||
const message =
|
||||
err instanceof Error && err.message ? err.message : t("common.errors.unableToSave");
|
||||
setError(message);
|
||||
}
|
||||
}, [isPending, draft, initialValue, computeError, onSubmit, onClose]);
|
||||
}, [isPending, draft, initialValue, computeError, onSubmit, onClose, t]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isPending) return;
|
||||
@@ -147,7 +150,7 @@ export function AdaptiveRenameModal({
|
||||
disabled={isPending}
|
||||
testID={cancelTestID}
|
||||
>
|
||||
Cancel
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -157,7 +160,7 @@ export function AdaptiveRenameModal({
|
||||
disabled={submitDisabled}
|
||||
testID={submitTestID}
|
||||
>
|
||||
{isPending ? "Saving..." : submitLabel}
|
||||
{isPending ? t("renameModal.saving") : (submitLabel ?? t("renameModal.rename"))}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { FileText, Layers, MessageSquare, Undo2 } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -42,7 +43,16 @@ export const RewindMenu = memo(function RewindMenu({
|
||||
testID = "rewind-menu",
|
||||
}: RewindMenuProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const items = useRewindCapabilities(capabilities);
|
||||
const { t } = useTranslation();
|
||||
const rewindLabels = useMemo(
|
||||
() => ({
|
||||
conversation: t("rewind.actions.conversation"),
|
||||
files: t("rewind.actions.files"),
|
||||
both: t("rewind.actions.both"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const items = useRewindCapabilities(capabilities, rewindLabels);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [pendingMode, setPendingMode] = useState<RewindMode | null>(null);
|
||||
const isLocked = isPendingProp || pendingMode !== null;
|
||||
@@ -79,10 +89,10 @@ export const RewindMenu = memo(function RewindMenu({
|
||||
const tooltipContent = useMemo(
|
||||
() => (
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Rewind to this message</Text>
|
||||
<Text style={styles.tooltipText}>{t("rewind.tooltip")}</Text>
|
||||
</TooltipContent>
|
||||
),
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
@@ -95,7 +105,7 @@ export const RewindMenu = memo(function RewindMenu({
|
||||
<TooltipTrigger asChild>
|
||||
<View style={styles.triggerSlot} collapsable={false}>
|
||||
<DropdownMenuTrigger
|
||||
accessibilityLabel="Rewind to this message"
|
||||
accessibilityLabel={t("rewind.tooltip")}
|
||||
accessibilityRole="button"
|
||||
disabled={isLocked}
|
||||
style={triggerStyle}
|
||||
@@ -114,7 +124,7 @@ export const RewindMenu = memo(function RewindMenu({
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" minWidth={220} side="bottom" testID={`${testID}-content`}>
|
||||
<View style={styles.warningHeader}>
|
||||
<Text style={styles.warningText}>This action cannot be undone</Text>
|
||||
<Text style={styles.warningText}>{t("rewind.warning")}</Text>
|
||||
</View>
|
||||
<DropdownMenuSeparator />
|
||||
{items.map((item) => (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -25,11 +26,12 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
isPending: boolean;
|
||||
} {
|
||||
const toast = useToast();
|
||||
const { t } = useTranslation();
|
||||
const composerRestore = useRewindComposerRestore();
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
mutationFn: async ({ mode }: RewindAgentInput) => {
|
||||
if (!input.client || !input.agentId || !input.messageId) {
|
||||
throw new Error("Daemon client not available");
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
await input.client.rewindAgent(input.agentId, input.messageId, mode);
|
||||
if (mode !== "files") {
|
||||
@@ -59,7 +61,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
composerRestore?.restoreTextIfComposerEmpty(variables.rewoundText);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to rewind agent");
|
||||
toast.error(error instanceof Error ? error.message : t("rewind.errors.failed"));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
export interface RewindMenuLabels {
|
||||
conversation: string;
|
||||
files: string;
|
||||
both: string;
|
||||
}
|
||||
|
||||
const DEFAULT_REWIND_MENU_LABELS: RewindMenuLabels = {
|
||||
conversation: "Rewind conversation",
|
||||
files: "Rewind files",
|
||||
both: "Rewind conversation and files",
|
||||
};
|
||||
|
||||
export function resolveRewindMenuItems(
|
||||
capabilities:
|
||||
| Pick<
|
||||
@@ -17,29 +29,31 @@ export function resolveRewindMenuItems(
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
labelsInput?: Partial<RewindMenuLabels>,
|
||||
): RewindMenuItem[] {
|
||||
if (!capabilities) {
|
||||
return [];
|
||||
}
|
||||
const labels = { ...DEFAULT_REWIND_MENU_LABELS, ...labelsInput };
|
||||
const items: RewindMenuItem[] = [];
|
||||
if (capabilities.supportsRewindConversation) {
|
||||
items.push({
|
||||
mode: "conversation",
|
||||
label: "Rewind conversation",
|
||||
label: labels.conversation,
|
||||
testID: "rewind-menu-conversation",
|
||||
});
|
||||
}
|
||||
if (capabilities.supportsRewindFiles) {
|
||||
items.push({
|
||||
mode: "files",
|
||||
label: "Rewind files",
|
||||
label: labels.files,
|
||||
testID: "rewind-menu-files",
|
||||
});
|
||||
}
|
||||
if (capabilities.supportsRewindBoth) {
|
||||
items.push({
|
||||
mode: "both",
|
||||
label: "Rewind conversation and files",
|
||||
label: labels.both,
|
||||
testID: "rewind-menu-both",
|
||||
});
|
||||
}
|
||||
@@ -48,6 +62,7 @@ export function resolveRewindMenuItems(
|
||||
|
||||
export function useRewindCapabilities(
|
||||
capabilities: Parameters<typeof resolveRewindMenuItems>[0],
|
||||
labels?: Partial<RewindMenuLabels>,
|
||||
): RewindMenuItem[] {
|
||||
return useMemo(() => resolveRewindMenuItems(capabilities), [capabilities]);
|
||||
return useMemo(() => resolveRewindMenuItems(capabilities, labels), [capabilities, labels]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { X } from "lucide-react-native";
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type SidebarCalloutActionVariant = "primary" | "secondary";
|
||||
|
||||
@@ -38,6 +39,7 @@ export function SidebarCallout({
|
||||
onDismiss,
|
||||
testID,
|
||||
}: SidebarCalloutProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const visibleActions = (actions ?? []).slice(0, 2);
|
||||
const hasHeader = title != null || icon != null;
|
||||
@@ -69,7 +71,7 @@ export function SidebarCallout({
|
||||
hitSlop={8}
|
||||
style={styles.dismissButton}
|
||||
testID={testID ? `${testID}-dismiss` : undefined}
|
||||
accessibilityLabel="Dismiss"
|
||||
accessibilityLabel={t("sidebarCallout.dismiss")}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{({ hovered }) => (
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type MutableRefObject,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { router, usePathname, type Href } from "expo-router";
|
||||
import {
|
||||
navigateToWorkspace,
|
||||
@@ -123,7 +124,10 @@ import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
|
||||
import {
|
||||
confirmRiskyWorktreeArchive,
|
||||
type WorktreeArchiveWarningLabels,
|
||||
} from "@/git/worktree-archive-warning";
|
||||
import {
|
||||
archiveWorkspaceOptimistically,
|
||||
archiveWorkspacesOptimistically,
|
||||
@@ -183,6 +187,40 @@ const syncedLoaderColorMapping = (theme: Theme) => ({
|
||||
: theme.colors.palette.amber[500],
|
||||
});
|
||||
|
||||
function getWorktreeArchiveWarningLabels(
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): WorktreeArchiveWarningLabels {
|
||||
return {
|
||||
title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }),
|
||||
confirm: t("workspace.git.actions.archiveWarning.confirm"),
|
||||
cancel: t("workspace.git.actions.archiveWarning.cancel"),
|
||||
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
|
||||
uncommittedChangesWithDiff: (diffStat) =>
|
||||
t("workspace.git.actions.archiveWarning.uncommittedChangesWithDiff", { diffStat }),
|
||||
addedLine: (count) =>
|
||||
t(
|
||||
count === 1
|
||||
? "workspace.git.actions.archiveWarning.addedLine"
|
||||
: "workspace.git.actions.archiveWarning.addedLines",
|
||||
{ count },
|
||||
),
|
||||
deletedLine: (count) =>
|
||||
t(
|
||||
count === 1
|
||||
? "workspace.git.actions.archiveWarning.deletedLine"
|
||||
: "workspace.git.actions.archiveWarning.deletedLines",
|
||||
{ count },
|
||||
),
|
||||
unpushedCommit: (count) =>
|
||||
t(
|
||||
count === 1
|
||||
? "workspace.git.actions.archiveWarning.unpushedCommit"
|
||||
: "workspace.git.actions.archiveWarning.unpushedCommits",
|
||||
{ count },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getPrIconUniMapping(state: PrHint["state"]) {
|
||||
switch (state) {
|
||||
case "merged":
|
||||
@@ -307,6 +345,7 @@ function getWorkspaceArchiveStatus(
|
||||
}
|
||||
|
||||
export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
||||
@@ -330,7 +369,9 @@ export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`Pull request #${hint.number}`}
|
||||
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
|
||||
number: hint.number,
|
||||
})}
|
||||
hitSlop={4}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={handlePress}
|
||||
@@ -572,6 +613,7 @@ function ProjectKebabMenu({
|
||||
onRemoveProject: () => void;
|
||||
removeProjectStatus: "idle" | "pending" | "success";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const handleOpenProjectSettings = useCallback(() => {
|
||||
if (projectKey.trim().length === 0) return;
|
||||
@@ -589,16 +631,16 @@ function ProjectKebabMenu({
|
||||
?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath })
|
||||
?.catch((error) => {
|
||||
console.warn("[sidebar] openNew failed", error);
|
||||
toast.error("Couldn't open a new window");
|
||||
toast.error(t("sidebar.project.actions.openNewWindowFailed"));
|
||||
});
|
||||
}, [projectPath, toast]);
|
||||
}, [projectPath, t, toast]);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={projectKebabStyle}
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel="Project actions"
|
||||
accessibilityLabel={t("sidebar.project.actions.menu")}
|
||||
testID={`sidebar-project-kebab-${projectKey}`}
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
@@ -610,7 +652,7 @@ function ProjectKebabMenu({
|
||||
leading={settingsLeadingIcon}
|
||||
onSelect={handleOpenProjectSettings}
|
||||
>
|
||||
Open project settings
|
||||
{t("sidebar.project.actions.openSettings")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canOpenInNewWindow ? (
|
||||
@@ -619,17 +661,17 @@ function ProjectKebabMenu({
|
||||
leading={openInNewWindowLeadingIcon}
|
||||
onSelect={handleOpenInNewWindow}
|
||||
>
|
||||
Open in new window
|
||||
{t("sidebar.project.actions.openNewWindow")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-remove-${projectKey}`}
|
||||
leading={trash2LeadingIcon}
|
||||
status={removeProjectStatus}
|
||||
pendingLabel="Removing..."
|
||||
pendingLabel={t("sidebar.project.actions.removing")}
|
||||
onSelect={onRemoveProject}
|
||||
>
|
||||
Remove project
|
||||
{t("sidebar.project.actions.remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -669,13 +711,16 @@ function WorkspaceRowRightGroup({
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||
const showKebabInSlot = showKebab && !showShortcut;
|
||||
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
||||
return (
|
||||
<>
|
||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||
{isCreating ? (
|
||||
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
|
||||
) : null}
|
||||
{shouldRenderActionSlot ? (
|
||||
<SidebarWorkspaceTrailingActionSlot>
|
||||
<SidebarWorkspaceTrailingActionBase
|
||||
@@ -733,6 +778,7 @@ function WorkspaceKebabMenu({
|
||||
archivePendingLabel?: string;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const archiveTrailing = useMemo(
|
||||
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
[archiveShortcutKeys],
|
||||
@@ -743,7 +789,7 @@ function WorkspaceKebabMenu({
|
||||
hitSlop={8}
|
||||
style={workspaceKebabStyle}
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel="Workspace actions"
|
||||
accessibilityLabel={t("sidebar.workspace.actions.menu")}
|
||||
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
@@ -755,7 +801,7 @@ function WorkspaceKebabMenu({
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
Copy path
|
||||
{t("sidebar.workspace.actions.copyPath")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
@@ -764,7 +810,7 @@ function WorkspaceKebabMenu({
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
Copy branch name
|
||||
{t("sidebar.workspace.actions.copyBranchName")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onRename ? (
|
||||
@@ -773,7 +819,7 @@ function WorkspaceKebabMenu({
|
||||
leading={renameLeadingIcon}
|
||||
onSelect={onRename}
|
||||
>
|
||||
Rename workspace
|
||||
{t("sidebar.workspace.actions.rename")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onMarkAsRead ? (
|
||||
@@ -793,7 +839,7 @@ function WorkspaceKebabMenu({
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
>
|
||||
{archiveLabel ?? "Archive"}
|
||||
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -912,6 +958,7 @@ function NewWorktreeButton({
|
||||
testID: string;
|
||||
showShortcutHint?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const newWorktreeKeys = useShortcutKeys("new-worktree");
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
@@ -940,7 +987,9 @@ function NewWorktreeButton({
|
||||
onPress={handlePress}
|
||||
disabled={loading}
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel={`Create a new workspace for ${displayName}`}
|
||||
accessibilityLabel={t("sidebar.workspace.actions.createWorkspaceFor", {
|
||||
projectName: displayName,
|
||||
})}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) =>
|
||||
@@ -959,7 +1008,9 @@ function NewWorktreeButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.projectActionTooltipRow}>
|
||||
<Text style={styles.projectActionTooltipText}>New workspace</Text>
|
||||
<Text style={styles.projectActionTooltipText}>
|
||||
{t("sidebar.workspace.actions.newWorkspace")}
|
||||
</Text>
|
||||
{showShortcutHint && newWorktreeKeys ? (
|
||||
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
||||
) : null}
|
||||
@@ -1481,6 +1532,7 @@ function WorkspaceRowWithMenu({
|
||||
canCopyBranchName: boolean;
|
||||
isCreating?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -1513,12 +1565,15 @@ function WorkspaceRowWithMenu({
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirmRiskyWorktreeArchive({
|
||||
worktreeName: workspace.name,
|
||||
isDirty: workspace.archiveHasUncommittedChanges,
|
||||
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
|
||||
diffStat: workspace.diffStat,
|
||||
});
|
||||
const confirmed = await confirmRiskyWorktreeArchive(
|
||||
{
|
||||
worktreeName: workspace.name,
|
||||
isDirty: workspace.archiveHasUncommittedChanges,
|
||||
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
|
||||
diffStat: workspace.diffStat,
|
||||
},
|
||||
getWorktreeArchiveWarningLabels(t),
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
@@ -1530,12 +1585,16 @@ function WorkspaceRowWithMenu({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!archiveDirectory) {
|
||||
toast.error("Workspace path not available");
|
||||
toast.error(t("sidebar.workspace.toasts.workspacePathUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1546,10 +1605,11 @@ function WorkspaceRowWithMenu({
|
||||
cwd: archiveDirectory,
|
||||
worktreePath: archiveDirectory,
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : "Failed to archive worktree";
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed");
|
||||
toast.error(message);
|
||||
});
|
||||
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
|
||||
}, [archiveWorktree, isArchiving, redirectAfterArchive, t, toast, workspace]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
void archiveWorktreeAfterConfirmation();
|
||||
@@ -1561,10 +1621,10 @@ function WorkspaceRowWithMenu({
|
||||
}
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Hide workspace?",
|
||||
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||
confirmLabel: "Hide",
|
||||
cancelLabel: "Cancel",
|
||||
title: t("sidebar.workspace.confirmations.hideTitle"),
|
||||
message: t("sidebar.workspace.confirmations.hideMessage", { workspaceName: workspace.name }),
|
||||
confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"),
|
||||
cancelLabel: t("sidebar.workspace.confirmations.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
@@ -1573,7 +1633,7 @@ function WorkspaceRowWithMenu({
|
||||
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
toast.error("Host is not connected");
|
||||
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1585,11 +1645,13 @@ function WorkspaceRowWithMenu({
|
||||
afterHide: redirectAfterArchive,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
void hideWorkspaceAfterConfirmation();
|
||||
@@ -1603,23 +1665,27 @@ function WorkspaceRowWithMenu({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(copyTargetDirectory);
|
||||
toast.copied("Path copied");
|
||||
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
|
||||
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
void Clipboard.setStringAsync(workspace.name);
|
||||
toast.copied("Branch name copied");
|
||||
}, [toast, workspace.name]);
|
||||
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
|
||||
}, [t, toast, workspace.name]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (branch: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
const targetCwd = requireWorkspaceExecutionDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
@@ -1627,7 +1693,7 @@ function WorkspaceRowWithMenu({
|
||||
});
|
||||
const payload = await client.renameBranch({ cwd: targetCwd, branch });
|
||||
if (!payload.success || payload.error) {
|
||||
throw new Error(payload.error?.message ?? "Failed to rename branch");
|
||||
throw new Error(payload.error?.message ?? t("sidebar.workspace.rename.invalidBranchName"));
|
||||
}
|
||||
return { targetCwd };
|
||||
},
|
||||
@@ -1654,11 +1720,14 @@ function WorkspaceRowWithMenu({
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const validateRenameSlug = useCallback((value: string): string | null => {
|
||||
const result = validateBranchSlug(slugify(value));
|
||||
if (result.valid) return null;
|
||||
return result.error ?? "Invalid branch name";
|
||||
}, []);
|
||||
const validateRenameSlug = useCallback(
|
||||
(value: string): string | null => {
|
||||
const result = validateBranchSlug(slugify(value));
|
||||
if (result.valid) return null;
|
||||
return result.error ?? t("sidebar.workspace.rename.invalidBranchName");
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
@@ -1700,9 +1769,17 @@ function WorkspaceRowWithMenu({
|
||||
isCreating={isCreating}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
|
||||
archiveLabel={
|
||||
isWorktree
|
||||
? t("sidebar.workspace.actions.archiveWorktree")
|
||||
: t("sidebar.workspace.actions.hideFromSidebar")
|
||||
}
|
||||
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
|
||||
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
|
||||
archivePendingLabel={
|
||||
isWorktree
|
||||
? t("sidebar.workspace.actions.archiving")
|
||||
: t("sidebar.workspace.actions.hiding")
|
||||
}
|
||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
@@ -1712,10 +1789,10 @@ function WorkspaceRowWithMenu({
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
title="Rename workspace"
|
||||
title={t("sidebar.workspace.rename.title")}
|
||||
initialValue={workspace.name}
|
||||
placeholder="branch-name"
|
||||
submitLabel="Rename"
|
||||
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||
validate={validateRenameSlug}
|
||||
maxLength={MAX_SLUG_LENGTH}
|
||||
onClose={handleCloseRename}
|
||||
@@ -1751,6 +1828,7 @@ function NonGitProjectRowWithMenuContent({
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const contextMenu = useContextMenu();
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
@@ -1769,10 +1847,12 @@ function NonGitProjectRowWithMenuContent({
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Hide workspace?",
|
||||
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||
confirmLabel: "Hide",
|
||||
cancelLabel: "Cancel",
|
||||
title: t("sidebar.workspace.confirmations.hideTitle"),
|
||||
message: t("sidebar.workspace.confirmations.hideMessage", {
|
||||
workspaceName: workspace.name,
|
||||
}),
|
||||
confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"),
|
||||
cancelLabel: t("sidebar.workspace.confirmations.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
@@ -1781,7 +1861,7 @@ function NonGitProjectRowWithMenuContent({
|
||||
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
toast.error("Host is not connected");
|
||||
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1794,13 +1874,15 @@ function NonGitProjectRowWithMenuContent({
|
||||
afterHide: redirectAfterArchive,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1831,11 +1913,11 @@ function NonGitProjectRowWithMenuContent({
|
||||
<ContextMenuItem
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
||||
status={isArchivingWorkspace ? "pending" : "idle"}
|
||||
pendingLabel="Hiding..."
|
||||
pendingLabel={t("sidebar.workspace.actions.hiding")}
|
||||
destructive
|
||||
onSelect={handleArchiveWorkspace}
|
||||
>
|
||||
Hide from sidebar
|
||||
{t("sidebar.workspace.actions.hideFromSidebar")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</>
|
||||
@@ -2212,6 +2294,7 @@ function ProjectBlock({
|
||||
);
|
||||
|
||||
const toast = useToast();
|
||||
const { t } = useTranslation();
|
||||
const [isRemovingProject, setIsRemovingProject] = useState(false);
|
||||
|
||||
const handleRemoveProject = useCallback(() => {
|
||||
@@ -2221,10 +2304,10 @@ function ProjectBlock({
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Remove project?",
|
||||
message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||
confirmLabel: "Remove",
|
||||
cancelLabel: "Cancel",
|
||||
title: t("sidebar.project.confirmations.removeTitle"),
|
||||
message: t("sidebar.project.confirmations.removeMessage", { projectName: displayName }),
|
||||
confirmLabel: t("sidebar.project.confirmations.removeConfirm"),
|
||||
cancelLabel: t("sidebar.project.confirmations.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
@@ -2233,7 +2316,7 @@ function ProjectBlock({
|
||||
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) {
|
||||
toast.error("Host is not connected");
|
||||
toast.error(t("sidebar.project.toasts.hostDisconnected"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2243,13 +2326,13 @@ function ProjectBlock({
|
||||
workspaces: project.workspaces,
|
||||
}).then((failures) => {
|
||||
if (failures.length > 0) {
|
||||
toast.error("Failed to remove some workspaces");
|
||||
toast.error(t("sidebar.project.toasts.removeFailed"));
|
||||
}
|
||||
setIsRemovingProject(false);
|
||||
return;
|
||||
});
|
||||
})();
|
||||
}, [isRemovingProject, serverId, displayName, toast, project.workspaces]);
|
||||
}, [isRemovingProject, serverId, displayName, t, toast, project.workspaces]);
|
||||
|
||||
const flattenedRowWorkspaceId =
|
||||
rowModel.kind === "workspace_link" ? rowModel.workspace.workspaceId : null;
|
||||
@@ -2476,6 +2559,7 @@ function ProjectModeList({
|
||||
}: Omit<SidebarWorkspaceListProps, "groupMode" | "isRefreshing" | "onRefresh"> & {
|
||||
pathname: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
@@ -2688,10 +2772,10 @@ function ProjectModeList({
|
||||
<>
|
||||
{projects.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyTitle}>No projects yet</Text>
|
||||
<Text style={styles.emptyText}>Add a project to get started</Text>
|
||||
<Text style={styles.emptyTitle}>{t("sidebar.project.empty.title")}</Text>
|
||||
<Text style={styles.emptyText}>{t("sidebar.project.empty.description")}</Text>
|
||||
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
|
||||
Add project
|
||||
{t("sidebar.actions.addProject")}
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -348,6 +349,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -419,7 +421,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
if (!confirmed) return;
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
toast.error("Host is not connected");
|
||||
toast.error(t("workspace.terminal.hostDisconnected"));
|
||||
return;
|
||||
}
|
||||
setIsArchivingWorkspace(true);
|
||||
@@ -434,7 +436,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
@@ -459,7 +461,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (branch: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) throw new Error("Host is not connected");
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
const targetCwd = requireWorkspaceExecutionDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { View, Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
@@ -660,6 +661,7 @@ function DragOverlayTabChipInner({
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const chipStyle = useMemo(
|
||||
@@ -684,7 +686,8 @@ function DragOverlayTabChipInner({
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{(presentation) => {
|
||||
const label = presentation.titleState === "loading" ? "Loading..." : presentation.label;
|
||||
const label =
|
||||
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
||||
|
||||
return (
|
||||
<View style={chipStyle}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
||||
@@ -169,6 +170,7 @@ export function TerminalPane({
|
||||
onOpenFileExplorer,
|
||||
onOpenWorkspaceFile,
|
||||
}: TerminalPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const isAppVisible = useAppVisible();
|
||||
const { theme } = useUnistyles();
|
||||
const { settings } = useAppSettings();
|
||||
@@ -750,7 +752,7 @@ export function TerminalPane({
|
||||
if (!client || !isConnected) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.stateText}>Host is not connected</Text>
|
||||
<Text style={styles.stateText}>{t("workspace.terminal.hostDisconnected")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
|
||||
@@ -49,6 +50,7 @@ export function useToastHost(): {
|
||||
dismiss: () => void;
|
||||
} {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const idRef = useRef(0);
|
||||
|
||||
@@ -85,13 +87,13 @@ export function useToastHost(): {
|
||||
() => ({
|
||||
show,
|
||||
copied: (label?: string) =>
|
||||
show(label ? `Copied ${label}` : "Copied", {
|
||||
show(label ? t("common.states.copiedLabel", { label }) : t("common.states.copied"), {
|
||||
variant: "success",
|
||||
icon: <CheckCircle2 size={18} color={theme.colors.foreground} />,
|
||||
}),
|
||||
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
|
||||
}),
|
||||
[show, theme.colors.foreground],
|
||||
[show, theme.colors.foreground, t],
|
||||
);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "react-native";
|
||||
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
|
||||
@@ -240,11 +242,12 @@ function WorktreeSetupDetailSection({
|
||||
function resolveSubAgentFallbackHeader(
|
||||
subAgentType: string | null | undefined,
|
||||
description: string | null | undefined,
|
||||
fallbackText: string,
|
||||
): string {
|
||||
if (subAgentType && description) {
|
||||
return `${subAgentType}: ${description}`;
|
||||
}
|
||||
return subAgentType ?? description ?? "Sub-agent activity";
|
||||
return subAgentType ?? description ?? fallbackText;
|
||||
}
|
||||
|
||||
interface SubAgentDetailProps {
|
||||
@@ -365,8 +368,13 @@ function SubAgentDetailSection({
|
||||
description,
|
||||
ds,
|
||||
}: SubAgentDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]);
|
||||
const fallbackHeader = resolveSubAgentFallbackHeader(subAgentType, description);
|
||||
const fallbackHeader = resolveSubAgentFallbackHeader(
|
||||
subAgentType,
|
||||
description,
|
||||
t("toolCallDetails.subAgentActivity"),
|
||||
);
|
||||
const hasActions = actions.length > 0;
|
||||
return (
|
||||
<View style={ds.sectionFillStyle}>
|
||||
@@ -594,7 +602,7 @@ interface UnknownDetail {
|
||||
output: unknown;
|
||||
}
|
||||
|
||||
function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNode[] {
|
||||
function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles, t: TFunction): ReactNode[] {
|
||||
const plainInputText =
|
||||
typeof detail.input === "string" && detail.output === null ? detail.input : null;
|
||||
|
||||
@@ -609,8 +617,8 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNod
|
||||
}
|
||||
|
||||
const sectionsFromTopLevel = [
|
||||
{ title: "Input", value: detail.input },
|
||||
{ title: "Output", value: detail.output },
|
||||
{ title: t("toolCallDetails.input"), value: detail.input },
|
||||
{ title: t("toolCallDetails.output"), value: detail.output },
|
||||
].filter((entry) =>
|
||||
hasMeaningfulToolCallDetail({
|
||||
type: "unknown",
|
||||
@@ -653,6 +661,7 @@ function buildDetailSections(
|
||||
detail: ToolCallDetail | undefined,
|
||||
diffLines: DiffLine[] | undefined,
|
||||
ds: DetailStyles,
|
||||
t: TFunction,
|
||||
): ReactNode[] {
|
||||
if (!detail) return [];
|
||||
if (detail.type === "shell") {
|
||||
@@ -723,15 +732,16 @@ function buildDetailSections(
|
||||
return [<PlainTextSection key="plain-text" text={detail.text} />];
|
||||
}
|
||||
if (detail.type === "unknown") {
|
||||
return buildUnknownSections(detail, ds);
|
||||
return buildUnknownSections(detail, ds, t);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={SECTION_TITLE_ERROR_STYLE}>Error</Text>
|
||||
<Text style={SECTION_TITLE_ERROR_STYLE}>{t("toolCallDetails.error")}</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
@@ -772,11 +782,12 @@ function ToolCallDetailsContentInner({
|
||||
fillAvailableHeight = false,
|
||||
showLoadingSkeleton = false,
|
||||
}: ToolCallDetailsContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
|
||||
const ds = useDetailStyles(detail, resolvedMaxHeight, fillAvailableHeight);
|
||||
const diffLines = useDiffLines(detail);
|
||||
|
||||
const sections: ReactNode[] = buildDetailSections(detail, diffLines, ds);
|
||||
const sections: ReactNode[] = buildDetailSections(detail, diffLines, ds, t);
|
||||
|
||||
if (errorText) {
|
||||
sections.push(<ErrorSection key="error" errorText={errorText} ds={ds} />);
|
||||
@@ -786,7 +797,7 @@ function ToolCallDetailsContentInner({
|
||||
if (showLoadingSkeleton) {
|
||||
return <LoadingSkeleton containerStyle={ds.loadingContainerStyle} />;
|
||||
}
|
||||
return <Text style={styles.emptyStateText}>No additional details available</Text>;
|
||||
return <Text style={styles.emptyStateText}>{t("toolCallDetails.empty")}</Text>;
|
||||
}
|
||||
|
||||
return <View style={ds.fullBleedContainerStyle}>{sections}</View>;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { File, Folder } from "lucide-react-native";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
|
||||
@@ -122,11 +123,14 @@ export function Autocomplete({
|
||||
onSelect,
|
||||
isLoading = false,
|
||||
errorMessage,
|
||||
loadingText = "Loading...",
|
||||
emptyText = "No results found",
|
||||
loadingText,
|
||||
emptyText,
|
||||
maxHeight = 220,
|
||||
}: AutocompleteProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedLoadingText = loadingText ?? t("common.states.loading");
|
||||
const resolvedEmptyText = emptyText ?? t("common.empty.noResults");
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
|
||||
const viewportHeightRef = useRef(0);
|
||||
@@ -213,7 +217,7 @@ export function Autocomplete({
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>{loadingText}</Text>
|
||||
<Text style={styles.emptyText}>{resolvedLoadingText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -233,7 +237,7 @@ export function Autocomplete({
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>{emptyText}</Text>
|
||||
<Text style={styles.emptyText}>{resolvedEmptyText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
@@ -1240,15 +1241,15 @@ export function Combobox({
|
||||
renderOption,
|
||||
onSearchQueryChange,
|
||||
searchable = true,
|
||||
placeholder = "Search...",
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyText = "No options match your search.",
|
||||
emptyText,
|
||||
allowCustomValue = false,
|
||||
customValuePrefix = "Use",
|
||||
customValueDescription,
|
||||
customValueKind,
|
||||
optionsPosition = "below-search",
|
||||
title = "Select",
|
||||
title,
|
||||
header,
|
||||
mobileChildrenScrollEnabled = true,
|
||||
presentation,
|
||||
@@ -1263,7 +1264,11 @@ export function Combobox({
|
||||
anchorRef,
|
||||
children,
|
||||
}: ComboboxProps): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedPlaceholder = placeholder ?? t("common.placeholders.search");
|
||||
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
|
||||
const resolvedTitle = title ?? t("common.actions.select");
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const titleColor = theme.colors.foreground;
|
||||
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
|
||||
@@ -1519,7 +1524,7 @@ export function Combobox({
|
||||
[],
|
||||
);
|
||||
|
||||
const effectiveSearchPlaceholder = searchPlaceholder ?? placeholder;
|
||||
const effectiveSearchPlaceholder = searchPlaceholder ?? resolvedPlaceholder;
|
||||
const hasChildren = Boolean(children);
|
||||
|
||||
if (isMobile) {
|
||||
@@ -1531,7 +1536,7 @@ export function Combobox({
|
||||
handleSheetDismiss={handleSheetDismiss}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
titleColor={titleColor}
|
||||
title={title}
|
||||
title={resolvedTitle}
|
||||
header={header}
|
||||
onClose={handleClose}
|
||||
stickyHeader={stickyHeader}
|
||||
@@ -1547,7 +1552,7 @@ export function Combobox({
|
||||
orderedVisibleOptions={orderedVisibleOptions}
|
||||
value={value}
|
||||
activeIndex={activeIndex}
|
||||
emptyText={emptyText}
|
||||
emptyText={resolvedEmptyText}
|
||||
handleSelect={handleSelect}
|
||||
renderOption={renderOption}
|
||||
>
|
||||
@@ -1580,7 +1585,7 @@ export function Combobox({
|
||||
orderedVisibleOptions={orderedVisibleOptions}
|
||||
value={value}
|
||||
activeIndex={activeIndex}
|
||||
emptyText={emptyText}
|
||||
emptyText={resolvedEmptyText}
|
||||
handleSelect={handleSelect}
|
||||
renderOption={renderOption}
|
||||
hasChildren={hasChildren}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
@@ -385,6 +386,7 @@ export function ContextMenuContent({
|
||||
mobileMode?: MobileMenuMode;
|
||||
testID?: string;
|
||||
}>): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const context = useContextMenuContext("ContextMenuContent");
|
||||
const { theme } = useUnistyles();
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
@@ -560,7 +562,7 @@ export function ContextMenuContent({
|
||||
<View style={styles.overlay}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Menu backdrop"
|
||||
accessibilityLabel={t("menu.backdrop")}
|
||||
style={styles.backdrop}
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
@@ -442,6 +443,7 @@ export function DropdownMenuContent({
|
||||
scrollable?: boolean;
|
||||
testID?: string;
|
||||
}>): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const { open, setOpen, triggerRef, flushPendingSelect } =
|
||||
useDropdownMenuContext("DropdownMenuContent");
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@@ -625,7 +627,7 @@ export function DropdownMenuContent({
|
||||
<View style={styles.overlay}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Menu backdrop"
|
||||
accessibilityLabel={t("menu.backdrop")}
|
||||
style={styles.backdrop}
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -157,6 +158,7 @@ export interface WelcomeScreenProps {
|
||||
|
||||
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const appVersion = resolveAppVersion();
|
||||
@@ -206,7 +208,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
? [
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: "Direct connection",
|
||||
label: t("pairing.connectionMethods.direct.title"),
|
||||
testID: "welcome-direct-connection",
|
||||
primary: true,
|
||||
icon: Link2,
|
||||
@@ -214,7 +216,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: "Paste pairing link",
|
||||
label: t("pairing.connectionMethods.pasteLink.title"),
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
@@ -224,7 +226,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
: [
|
||||
{
|
||||
key: "scan-qr",
|
||||
label: "Scan QR code",
|
||||
label: t("pairing.connectionMethods.scanQr.title"),
|
||||
testID: "welcome-scan-qr",
|
||||
primary: true,
|
||||
icon: QrCode,
|
||||
@@ -232,7 +234,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
},
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: "Direct connection",
|
||||
label: t("pairing.connectionMethods.direct.title"),
|
||||
testID: "welcome-direct-connection",
|
||||
primary: false,
|
||||
icon: Link2,
|
||||
@@ -240,7 +242,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: "Paste pairing link",
|
||||
label: t("pairing.connectionMethods.pasteLink.title"),
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
@@ -264,8 +266,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
<View style={styles.content}>
|
||||
<PaseoLogo size={96} />
|
||||
<View style={styles.copyBlock}>
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<Text style={styles.subtitle}>Connect your computer to get started</Text>
|
||||
<Text style={styles.title}>{t("onboarding.title")}</Text>
|
||||
<Text style={styles.subtitle}>{t("onboarding.subtitle")}</Text>
|
||||
{isNative ? (
|
||||
<Pressable style={styles.setupLink} onPress={handleOpenPaseoSite}>
|
||||
<Text style={styles.setupLinkText}>paseo.sh</Text>
|
||||
@@ -288,7 +290,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
style={styles.settingsButton}
|
||||
testID="welcome-open-settings"
|
||||
>
|
||||
Settings
|
||||
{t("onboarding.actions.settings")}
|
||||
</Button>
|
||||
</View>
|
||||
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Dimensions, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native";
|
||||
@@ -212,6 +213,7 @@ function WorkspaceHoverCardContent({
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
contentRef: React.RefObject<View | null>;
|
||||
}): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||
@@ -274,7 +276,7 @@ function WorkspaceHoverCardContent({
|
||||
collapsable={false}
|
||||
onLayout={handleLayout}
|
||||
accessibilityRole="menu"
|
||||
accessibilityLabel="Workspace scripts"
|
||||
accessibilityLabel={t("workspace.hoverCard.scriptsAccessibility")}
|
||||
testID="workspace-hover-card"
|
||||
style={styles.card}
|
||||
frameStyle={frameStyle}
|
||||
@@ -373,6 +375,7 @@ function ChecksSummaryContent({
|
||||
checks: NonNullable<PrHint["checks"]>;
|
||||
hovered: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { passed, failed, pending } = getChecksSummaryCounts(checks);
|
||||
|
||||
const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
|
||||
@@ -385,7 +388,7 @@ function ChecksSummaryContent({
|
||||
) : (
|
||||
<ThemedGitHubIcon size={12} uniProps={iconUniProps} />
|
||||
)}
|
||||
<Text style={labelStyle}>Checks</Text>
|
||||
<Text style={labelStyle}>{t("workspace.git.pr.sections.checks")}</Text>
|
||||
<View style={styles.checksSummaryCounts}>
|
||||
<ChecksSummaryPill count={passed} kind="passed" />
|
||||
<ChecksSummaryPill count={failed} kind="failed" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Image, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Composer } from "@/composer";
|
||||
@@ -96,8 +97,13 @@ async function callWorkspaceCreation({
|
||||
return connectedClient.openProject(input.cwd);
|
||||
}
|
||||
|
||||
function failureMessageForCreationMethod(method: "create_worktree" | "open_project") {
|
||||
return method === "create_worktree" ? "Failed to create worktree" : "Failed to open project";
|
||||
function failureMessageForCreationMethod(
|
||||
method: "create_worktree" | "open_project",
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
) {
|
||||
return method === "create_worktree"
|
||||
? t("workspaceSetup.errors.failedCreateWorktree")
|
||||
: t("workspaceSetup.errors.failedOpenProject");
|
||||
}
|
||||
|
||||
function buildCreateAgentOptions({
|
||||
@@ -140,6 +146,7 @@ function buildCreateAgentOptions({
|
||||
}
|
||||
|
||||
export function WorkspaceSetupDialog() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
|
||||
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
|
||||
@@ -170,7 +177,7 @@ export function WorkspaceSetupDialog() {
|
||||
});
|
||||
const composerState = chatDraft.composerState;
|
||||
if (!composerState && pendingWorkspaceSetup) {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
throw new Error(t("workspaceSetup.errors.composerStateRequired"));
|
||||
}
|
||||
|
||||
const { icon: projectIcon } = useProjectIconQuery({
|
||||
@@ -218,15 +225,15 @@ export function WorkspaceSetupDialog() {
|
||||
|
||||
const withConnectedClient = useCallback(() => {
|
||||
if (!client || !isConnected) {
|
||||
throw new Error("Host is not connected");
|
||||
throw new Error(t("workspaceSetup.errors.hostDisconnected"));
|
||||
}
|
||||
return client;
|
||||
}, [client, isConnected]);
|
||||
}, [client, isConnected, t]);
|
||||
|
||||
const ensureWorkspace = useCallback(
|
||||
async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => {
|
||||
if (!pendingWorkspaceSetup) {
|
||||
throw new Error("No workspace setup is pending");
|
||||
throw new Error(t("workspaceSetup.errors.pendingRequired"));
|
||||
}
|
||||
|
||||
if (createdWorkspace) {
|
||||
@@ -242,7 +249,7 @@ export function WorkspaceSetupDialog() {
|
||||
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(
|
||||
payload.error ?? failureMessageForCreationMethod(pendingWorkspaceSetup.creationMethod),
|
||||
payload.error ?? failureMessageForCreationMethod(pendingWorkspaceSetup.creationMethod, t),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,6 +266,7 @@ export function WorkspaceSetupDialog() {
|
||||
mergeWorkspaces,
|
||||
pendingWorkspaceSetup,
|
||||
setHasHydratedWorkspaces,
|
||||
t,
|
||||
withConnectedClient,
|
||||
],
|
||||
);
|
||||
@@ -284,10 +292,10 @@ export function WorkspaceSetupDialog() {
|
||||
const ensuredWorkspace = await ensureWorkspace({ cwd, attachments });
|
||||
const connectedClient = withConnectedClient();
|
||||
if (!composerState) {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
throw new Error(t("workspaceSetup.errors.composerStateRequired"));
|
||||
}
|
||||
if (!composerState.selectedProvider) {
|
||||
throw new Error("Select a model");
|
||||
throw new Error(t("workspaceSetup.errors.selectModel"));
|
||||
}
|
||||
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
||||
@@ -334,6 +342,7 @@ export function WorkspaceSetupDialog() {
|
||||
serverId,
|
||||
setAgents,
|
||||
ensureWorkspace,
|
||||
t,
|
||||
toast,
|
||||
withConnectedClient,
|
||||
],
|
||||
@@ -392,8 +401,8 @@ export function WorkspaceSetupDialog() {
|
||||
);
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({ title: "Create workspace", subtitle: subtitleContent }),
|
||||
[subtitleContent],
|
||||
() => ({ title: t("workspaceSetup.title"), subtitle: subtitleContent }),
|
||||
[subtitleContent, t],
|
||||
);
|
||||
|
||||
if (!pendingWorkspaceSetup || !sourceDirectory) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PaseoConfigRaw } from "@getpaseo/protocol/messages";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
export interface WorktreeSetupWorkspaceInput {
|
||||
@@ -64,10 +65,9 @@ export function buildWorktreeSetupCalloutPolicy(
|
||||
id: calloutKey,
|
||||
dismissalKey: calloutKey,
|
||||
priority: 100,
|
||||
title: "Set up worktree scripts",
|
||||
description:
|
||||
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
|
||||
actionLabel: "Open project settings",
|
||||
title: i18n.t("sidebar.worktreeSetup.title"),
|
||||
description: i18n.t("sidebar.worktreeSetup.description"),
|
||||
actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"),
|
||||
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
|
||||
testID: `worktree-setup-callout-${project.projectKey}`,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export interface QueuedComposerMessage {
|
||||
id: string;
|
||||
@@ -244,6 +245,7 @@ export interface SendQueuedComposerMessageNowInput {
|
||||
messageId: string;
|
||||
queue: QueueWriter;
|
||||
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
|
||||
failedToSendMessage?: string;
|
||||
}
|
||||
|
||||
export type SendQueuedComposerMessageNowResult =
|
||||
@@ -275,7 +277,10 @@ export async function sendQueuedComposerMessageNow(
|
||||
});
|
||||
return {
|
||||
status: "failed",
|
||||
errorMessage: error instanceof Error ? error.message : "Failed to send message",
|
||||
errorMessage:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: (input.failedToSendMessage ?? i18n.t("composer.errors.failedToSend")),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -56,7 +57,7 @@ import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manife
|
||||
import {
|
||||
getFeatureHighlightColor,
|
||||
getFeatureTooltip,
|
||||
getAgentControlHint,
|
||||
getAgentControlHintKey,
|
||||
formatThinkingOptionLabel,
|
||||
resolveAgentModelSelection,
|
||||
} from "@/composer/agent-controls/utils";
|
||||
@@ -415,6 +416,7 @@ function ControlledAgentControls({
|
||||
isCompactLayout,
|
||||
}: ControlledAgentControlsProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
|
||||
@@ -432,7 +434,11 @@ function ControlledAgentControls({
|
||||
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0,
|
||||
);
|
||||
|
||||
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
|
||||
const displayProvider = findOptionLabel(
|
||||
providerOptions,
|
||||
selectedProviderId,
|
||||
t("agentControls.provider.fallback"),
|
||||
);
|
||||
const formattedThinkingOptions = useMemo(
|
||||
() => toThinkingControlOptions(thinkingOptions),
|
||||
[thinkingOptions],
|
||||
@@ -440,7 +446,7 @@ function ControlledAgentControls({
|
||||
const displayThinking = findOptionLabel(
|
||||
formattedThinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
formattedThinkingOptions[0]?.label ?? "Unknown",
|
||||
formattedThinkingOptions[0]?.label ?? t("agentControls.thinking.unknown"),
|
||||
);
|
||||
|
||||
const ProviderIcon = resolveProviderIcon(provider);
|
||||
@@ -709,6 +715,7 @@ const DESKTOP_SEARCH_THRESHOLD = 6;
|
||||
|
||||
function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
provider,
|
||||
providerOptions,
|
||||
@@ -764,7 +771,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
onPress={handleProviderPress}
|
||||
style={providerPressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent provider"
|
||||
accessibilityLabel={t("agentControls.provider.select")}
|
||||
testID="agent-provider-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayProvider}</Text>
|
||||
@@ -805,7 +812,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getAgentControlHint("model")}</Text>
|
||||
<Text style={styles.tooltipText}>{t(getAgentControlHintKey("model"))}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@@ -821,7 +828,9 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
onPress={handleThinkingPress}
|
||||
style={thinkingPressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select thinking option (${displayThinking})`}
|
||||
accessibilityLabel={t("agentControls.thinking.selectWithValue", {
|
||||
value: displayThinking,
|
||||
})}
|
||||
testID="agent-thinking-selector"
|
||||
>
|
||||
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
@@ -830,7 +839,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getAgentControlHint("thinking")}</Text>
|
||||
<Text style={styles.tooltipText}>{t(getAgentControlHintKey("thinking"))}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
@@ -901,6 +910,7 @@ interface SheetAgentControlsContentProps {
|
||||
|
||||
function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
provider,
|
||||
selectedModelId,
|
||||
@@ -936,6 +946,10 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
|
||||
const hasThinking = comboboxThinkingOptions.length > 0;
|
||||
const hasFeatures = Boolean(features && features.length > 0);
|
||||
const featuresSheetHeader = useMemo<SheetHeader>(
|
||||
() => ({ title: t("agentControls.features.title") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]);
|
||||
const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]);
|
||||
@@ -1012,7 +1026,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
disabled={disabled || !canSelectThinking}
|
||||
style={thinkingButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select thinking option"
|
||||
accessibilityLabel={t("agentControls.thinking.select")}
|
||||
testID="agent-controls-thinking"
|
||||
>
|
||||
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
@@ -1025,7 +1039,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
disabled={disabled}
|
||||
style={featuresButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open agent features"
|
||||
accessibilityLabel={t("agentControls.features.open")}
|
||||
testID="agent-controls-features"
|
||||
>
|
||||
<Settings2 size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
@@ -1038,7 +1052,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
value={selectedThinkingOptionId ?? ""}
|
||||
onSelect={handleSelectThinkingAndClose}
|
||||
searchable={false}
|
||||
title="Thinking"
|
||||
title={t("agentControls.thinking.title")}
|
||||
open={activeSheet === "thinking"}
|
||||
onOpenChange={handleThinkingSheetOpenChange}
|
||||
anchorRef={thinkingAnchorRef}
|
||||
@@ -1047,7 +1061,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
) : null}
|
||||
|
||||
<AdaptiveModalSheet
|
||||
header={FEATURES_SHEET_HEADER}
|
||||
header={featuresSheetHeader}
|
||||
visible={activeSheet === "features"}
|
||||
onClose={handleCloseSheet}
|
||||
testID="agent-features-sheet"
|
||||
@@ -1206,6 +1220,7 @@ function SheetFeatureItem({
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const featureSelector: AgentControlSelector = `feature-${feature.id}`;
|
||||
|
||||
const handleFeatureOpenChange = useMemo(
|
||||
@@ -1257,7 +1272,9 @@ function SheetFeatureItem({
|
||||
)}
|
||||
/>
|
||||
<Text style={styles.sheetSelectText}>{feature.label}</Text>
|
||||
<Text style={styles.modeBadgeText}>{feature.value ? "On" : "Off"}</Text>
|
||||
<Text style={styles.modeBadgeText}>
|
||||
{feature.value ? t("agentControls.features.on") : t("agentControls.features.off")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -1344,8 +1361,6 @@ function ThinkingComboboxOption({
|
||||
);
|
||||
}
|
||||
|
||||
const FEATURES_SHEET_HEADER: SheetHeader = { title: "Features" };
|
||||
|
||||
export const AgentControls = memo(function AgentControls({
|
||||
agentId,
|
||||
serverId,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type ComponentType,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
@@ -108,6 +109,7 @@ function AgentModeControlView({
|
||||
disabled = false,
|
||||
}: AgentModeControlViewProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -182,14 +184,14 @@ function AgentModeControlView({
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: "Mode",
|
||||
title: t("agentControls.mode.title"),
|
||||
search: {
|
||||
onChange: setSearchQuery,
|
||||
placeholder: "Search modes...",
|
||||
placeholder: t("agentControls.mode.searchPlaceholder"),
|
||||
testID: "mode-search-input",
|
||||
},
|
||||
}),
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!selectedMode) return null;
|
||||
@@ -203,7 +205,9 @@ function AgentModeControlView({
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select agent mode (${selectedModeLabel})`}
|
||||
accessibilityLabel={t("agentControls.mode.selectWithValue", {
|
||||
value: selectedModeLabel,
|
||||
})}
|
||||
testID="mode-control"
|
||||
>
|
||||
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
|
||||
|
||||
@@ -3,17 +3,17 @@ import {
|
||||
formatAgentModeLabel,
|
||||
getFeatureHighlightColor,
|
||||
getFeatureTooltip,
|
||||
getAgentControlHint,
|
||||
getAgentControlHintKey,
|
||||
formatThinkingOptionLabel,
|
||||
normalizeModelId,
|
||||
resolveAgentModelSelection,
|
||||
} from "./utils";
|
||||
|
||||
describe("getAgentControlHint", () => {
|
||||
it("explains what each editable agent control does", () => {
|
||||
expect(getAgentControlHint("thinking")).toBe("Thinking mode");
|
||||
expect(getAgentControlHint("model")).toBe("Change model");
|
||||
expect(getAgentControlHint("mode")).toBe("Change permission mode");
|
||||
describe("getAgentControlHintKey", () => {
|
||||
it("returns translation keys for each editable agent control hint", () => {
|
||||
expect(getAgentControlHintKey("thinking")).toBe("agentControls.hints.thinking");
|
||||
expect(getAgentControlHintKey("model")).toBe("agentControls.hints.model");
|
||||
expect(getAgentControlHintKey("mode")).toBe("agentControls.hints.mode");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export type ExplainedAgentControl = "mode" | "model" | "thinking";
|
||||
export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow";
|
||||
export type AgentControlHintKey =
|
||||
| "agentControls.hints.thinking"
|
||||
| "agentControls.hints.model"
|
||||
| "agentControls.hints.mode";
|
||||
|
||||
export function getAgentControlHint(selector: ExplainedAgentControl): string {
|
||||
export function getAgentControlHintKey(selector: ExplainedAgentControl): AgentControlHintKey {
|
||||
switch (selector) {
|
||||
case "thinking":
|
||||
return "Thinking mode";
|
||||
return "agentControls.hints.thinking";
|
||||
case "model":
|
||||
return "Change model";
|
||||
return "agentControls.hints.model";
|
||||
case "mode":
|
||||
return "Change permission mode";
|
||||
return "agentControls.hints.mode";
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
@@ -79,7 +84,7 @@ export function formatThinkingOptionLabel(option: ControlLabelInput): string {
|
||||
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
|
||||
|
||||
if (compactId === "xhigh" || compactLabel === "xhigh") {
|
||||
return "Extra high";
|
||||
return i18n.t("agentControls.thinking.extraHigh");
|
||||
}
|
||||
|
||||
return formatControlLabel(option, true);
|
||||
@@ -143,17 +148,19 @@ function resolveModelDisplay(
|
||||
selectedModel: AgentModelDefinition | null,
|
||||
preferredModelId: string | null,
|
||||
fallbackModel: AgentModelDefinition | null,
|
||||
unknownModelLabel: string,
|
||||
): { activeModelId: string | null; displayModel: string } {
|
||||
return {
|
||||
activeModelId: selectedModel?.id ?? preferredModelId ?? null,
|
||||
displayModel:
|
||||
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model",
|
||||
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? unknownModelLabel,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveThinkingDisplay(
|
||||
effectiveThinking: ThinkingOption | null,
|
||||
selectedThinkingId: string | null,
|
||||
unknownThinkingLabel: string,
|
||||
): string {
|
||||
if (effectiveThinking) {
|
||||
return formatThinkingOptionLabel(effectiveThinking);
|
||||
@@ -163,7 +170,7 @@ function resolveThinkingDisplay(
|
||||
return formatThinkingOptionLabel({ id: selectedThinkingId });
|
||||
}
|
||||
|
||||
return "Unknown";
|
||||
return unknownThinkingLabel;
|
||||
}
|
||||
|
||||
export function resolveAgentModelSelection(input: {
|
||||
@@ -189,13 +196,18 @@ export function resolveAgentModelSelection(input: {
|
||||
selectedModel,
|
||||
preferredModelId,
|
||||
fallbackModel,
|
||||
i18n.t("agentControls.model.unknown"),
|
||||
);
|
||||
|
||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
||||
const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel);
|
||||
const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId);
|
||||
const selectedThinkingId = effectiveThinking?.id ?? null;
|
||||
const displayThinking = resolveThinkingDisplay(effectiveThinking, selectedThinkingId);
|
||||
const displayThinking = resolveThinkingDisplay(
|
||||
effectiveThinking,
|
||||
selectedThinkingId,
|
||||
i18n.t("agentControls.thinking.unknown"),
|
||||
);
|
||||
|
||||
return {
|
||||
selectedModel,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { MessageSquareCode, MousePointer2 } from "lucide-react-native";
|
||||
import type {
|
||||
@@ -231,14 +232,15 @@ function WorkspaceAttachmentPill({
|
||||
onOpen,
|
||||
onRemove,
|
||||
}: WorkspaceAttachmentPillProps) {
|
||||
const { t } = useTranslation();
|
||||
let label: string;
|
||||
if (attachment.kind === "browser_element") {
|
||||
label = `Element · ${attachment.attachment.tag}`;
|
||||
label = t("composer.attachments.browserElement", { tag: attachment.attachment.tag });
|
||||
} else {
|
||||
label =
|
||||
attachment.commentCount === 1
|
||||
? "Review · 1 comment"
|
||||
: `Review · ${attachment.commentCount} comments`;
|
||||
? t("message.attachments.reviewOne")
|
||||
: t("message.attachments.reviewMany", { count: attachment.commentCount });
|
||||
}
|
||||
const handleOpen = useCallback(() => {
|
||||
onOpen(attachment);
|
||||
@@ -253,13 +255,13 @@ function WorkspaceAttachmentPill({
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={
|
||||
attachment.kind === "browser_element"
|
||||
? "Open browser element attachment"
|
||||
: "Open review attachment"
|
||||
? t("composer.attachments.openBrowserElement")
|
||||
: t("composer.attachments.openReview")
|
||||
}
|
||||
removeAccessibilityLabel={
|
||||
attachment.kind === "browser_element"
|
||||
? "Remove browser element attachment"
|
||||
: "Remove review attachment"
|
||||
? t("composer.attachments.removeBrowserElement")
|
||||
: t("composer.attachments.removeReview")
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useReducer } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
@@ -105,6 +106,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
onCreateSuccess,
|
||||
onCreateError,
|
||||
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
|
||||
const { t } = useTranslation();
|
||||
const [machine, dispatch] = useReducer(
|
||||
reducer,
|
||||
initialAttempt,
|
||||
@@ -163,7 +165,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
|
||||
const pendingServerId = getPendingServerId();
|
||||
if (!pendingServerId) {
|
||||
const error = new Error("No host selected");
|
||||
const error = new Error(t("composer.errors.noHostSelected"));
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||
throw error;
|
||||
}
|
||||
@@ -204,7 +206,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
|
||||
await onCreateSuccess({ result: createResult.result, attempt });
|
||||
} catch (error) {
|
||||
const resolved = error instanceof Error ? error : new Error("Failed to create agent");
|
||||
const resolved =
|
||||
error instanceof Error ? error : new Error(t("composer.errors.failedToCreateAgent"));
|
||||
dispatch({ type: "CREATE_FAILED", message: resolved.message });
|
||||
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
|
||||
clearPendingCreateAttempt({ draftId });
|
||||
@@ -222,6 +225,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
onBeforeSubmit,
|
||||
onCreateError,
|
||||
onCreateSuccess,
|
||||
t,
|
||||
updatePendingAgentId,
|
||||
],
|
||||
);
|
||||
@@ -229,7 +233,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
const handleCreateFromInput = useCallback(
|
||||
async ({ text, attachments, cwd }: SubmitContext) => {
|
||||
if (isSubmitting) {
|
||||
throw new Error("Already loading");
|
||||
throw new Error(t("composer.errors.alreadyLoading"));
|
||||
}
|
||||
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
|
||||
@@ -238,7 +242,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
|
||||
const trimmedPrompt = text.trim();
|
||||
if (!trimmedPrompt && !allowEmptyText) {
|
||||
const error = new Error("Initial prompt is required");
|
||||
const error = new Error(t("composer.errors.initialPromptRequired"));
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||
throw error;
|
||||
}
|
||||
@@ -256,7 +260,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
|
||||
const pendingServerId = getPendingServerId();
|
||||
if (!pendingServerId) {
|
||||
const error = new Error("No host selected");
|
||||
const error = new Error(t("composer.errors.noHostSelected"));
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||
throw error;
|
||||
}
|
||||
@@ -294,6 +298,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
onCreateStart,
|
||||
runCreateAttempt,
|
||||
setPendingCreateAttempt,
|
||||
t,
|
||||
validateBeforeSubmit,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Import as ImportIcon } from "lucide-react-native";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -13,6 +14,7 @@ interface ComposerImportPillProps {
|
||||
}
|
||||
|
||||
export function ComposerImportPill({ onPress, disabled = false }: ComposerImportPillProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
@@ -22,7 +24,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
|
||||
<Pressable
|
||||
testID="composer-import-agent-pill"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Import session"
|
||||
accessibilityLabel={t("importSession.title")}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
onHoverIn={handleHoverIn}
|
||||
@@ -31,7 +33,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
|
||||
>
|
||||
<ThemedImportIcon size={14} uniProps={iconColorMapping} />
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
Import session
|
||||
{t("importSession.title")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Keyboard, ScrollView, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -130,6 +131,8 @@ async function submitDraftCreateRequest(input: {
|
||||
effectiveThinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
};
|
||||
hostDisconnectedMessage: string;
|
||||
selectModelMessage: string;
|
||||
}): Promise<{ agentId: string | null; result: AgentSnapshotPayload }> {
|
||||
const {
|
||||
attempt,
|
||||
@@ -146,12 +149,12 @@ async function submitDraftCreateRequest(input: {
|
||||
invariant(workspaceDirectory, "Workspace directory is required");
|
||||
invariant(workspaceExecutionAuthority, "Workspace authority is required");
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
throw new Error(input.hostDisconnectedMessage);
|
||||
}
|
||||
|
||||
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
throw new Error("Select a model");
|
||||
throw new Error(input.selectModelMessage);
|
||||
}
|
||||
const modeIdOverride = resolveDraftModeIdOverride({
|
||||
autoSubmitConfig,
|
||||
@@ -199,6 +202,7 @@ function buildDraftAgentSnapshot(input: {
|
||||
selectedProvider: string | null;
|
||||
agentControls: { features?: Agent["features"] };
|
||||
};
|
||||
selectModelMessage: string;
|
||||
}): Agent {
|
||||
const { attempt, serverId, tabId, workspaceDirectory, autoSubmitConfig, composerState } = input;
|
||||
invariant(workspaceDirectory, "Workspace directory is required");
|
||||
@@ -213,7 +217,7 @@ function buildDraftAgentSnapshot(input: {
|
||||
});
|
||||
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
throw new Error("Select a model");
|
||||
throw new Error(input.selectModelMessage);
|
||||
}
|
||||
return {
|
||||
serverId,
|
||||
@@ -309,6 +313,7 @@ export function WorkspaceDraftAgentTab({
|
||||
onOpenWorkspaceFile,
|
||||
onOpenImportSheet,
|
||||
}: WorkspaceDraftAgentTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -456,6 +461,7 @@ export function WorkspaceDraftAgentTab({
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
}),
|
||||
createRequest: async ({ attempt, text, images, attachments }) =>
|
||||
submitDraftCreateRequest({
|
||||
@@ -468,6 +474,8 @@ export function WorkspaceDraftAgentTab({
|
||||
workspaceExecutionAuthority,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
}),
|
||||
onCreateSuccess: ({ result }) => {
|
||||
clearDraftInput("sent");
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Image,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
@@ -144,8 +146,10 @@ function resolveCompactLayout(override: boolean | undefined, formFactor: boolean
|
||||
return override ?? formFactor;
|
||||
}
|
||||
|
||||
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
|
||||
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
|
||||
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean, t: TFunction): string {
|
||||
return isDesktopWebBreakpoint
|
||||
? t("composer.placeholders.desktop")
|
||||
: t("composer.placeholders.mobile");
|
||||
}
|
||||
|
||||
function resolveGithubSearchEnabled(
|
||||
@@ -252,6 +256,12 @@ interface RenderAttachmentTrayArgs {
|
||||
isComposerLocked: boolean;
|
||||
handleOpenAttachment: (attachment: ComposerAttachment) => void;
|
||||
handleRemoveAttachment: (index: number) => void;
|
||||
labels: {
|
||||
openImage: string;
|
||||
removeImage: string;
|
||||
openGithub: (kind: string, number: number) => string;
|
||||
removeGithub: (kind: string, number: number) => string;
|
||||
};
|
||||
}
|
||||
|
||||
function renderComposerFooter(
|
||||
@@ -272,8 +282,13 @@ function renderComposerFooter(
|
||||
}
|
||||
|
||||
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
|
||||
const { selectedAttachments, isComposerLocked, handleOpenAttachment, handleRemoveAttachment } =
|
||||
args;
|
||||
const {
|
||||
selectedAttachments,
|
||||
isComposerLocked,
|
||||
handleOpenAttachment,
|
||||
handleRemoveAttachment,
|
||||
labels,
|
||||
} = args;
|
||||
if (selectedAttachments.length === 0) return null;
|
||||
return (
|
||||
<View style={styles.attachmentTray} testID="composer-attachment-tray">
|
||||
@@ -284,6 +299,7 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu
|
||||
disabled: isComposerLocked,
|
||||
onOpen: handleOpenAttachment,
|
||||
onRemove: handleRemoveAttachment,
|
||||
labels,
|
||||
}),
|
||||
)}
|
||||
</View>
|
||||
@@ -294,10 +310,13 @@ interface RenderQueueTrackArgs {
|
||||
queuedMessages: readonly QueuedMessage[];
|
||||
handleEditQueuedMessage: (id: string) => void;
|
||||
handleSendQueuedNow: (id: string) => Promise<void>;
|
||||
editLabel: string;
|
||||
sendNowLabel: string;
|
||||
}
|
||||
|
||||
function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
|
||||
const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow } = args;
|
||||
const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow, editLabel, sendNowLabel } =
|
||||
args;
|
||||
if (queuedMessages.length === 0) return null;
|
||||
return (
|
||||
<View style={styles.queueTrack}>
|
||||
@@ -307,6 +326,8 @@ function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
|
||||
item={item}
|
||||
onEdit={handleEditQueuedMessage}
|
||||
onSendNow={handleSendQueuedNow}
|
||||
editLabel={editLabel}
|
||||
sendNowLabel={sendNowLabel}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -319,10 +340,11 @@ interface RenderComposerAttachmentPillArgs {
|
||||
disabled: boolean;
|
||||
onOpen: (attachment: ComposerAttachment) => void;
|
||||
onRemove: (index: number) => void;
|
||||
labels: RenderAttachmentTrayArgs["labels"];
|
||||
}
|
||||
|
||||
function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): ReactElement {
|
||||
const { attachment, index, disabled, onOpen, onRemove } = args;
|
||||
const { attachment, index, disabled, onOpen, onRemove, labels } = args;
|
||||
if (attachment.kind === "image") {
|
||||
return (
|
||||
<ImageAttachmentPill
|
||||
@@ -332,6 +354,8 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
|
||||
disabled={disabled}
|
||||
onOpen={onOpen}
|
||||
onRemove={onRemove}
|
||||
openLabel={labels.openImage}
|
||||
removeLabel={labels.removeImage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -352,6 +376,8 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
|
||||
disabled={disabled}
|
||||
onOpen={onOpen}
|
||||
onRemove={onRemove}
|
||||
openLabel={labels.openGithub}
|
||||
removeLabel={labels.removeGithub}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -472,9 +498,17 @@ interface QueuedMessageRowProps {
|
||||
item: QueuedMessage;
|
||||
onEdit: (id: string) => void;
|
||||
onSendNow: (id: string) => void;
|
||||
editLabel: string;
|
||||
sendNowLabel: string;
|
||||
}
|
||||
|
||||
function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
|
||||
function QueuedMessageRow({
|
||||
item,
|
||||
onEdit,
|
||||
onSendNow,
|
||||
editLabel,
|
||||
sendNowLabel,
|
||||
}: QueuedMessageRowProps) {
|
||||
const handleEdit = useCallback(() => {
|
||||
onEdit(item.id);
|
||||
}, [onEdit, item.id]);
|
||||
@@ -490,7 +524,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
|
||||
<Pressable
|
||||
onPress={handleEdit}
|
||||
style={styles.queueActionButton}
|
||||
accessibilityLabel="Edit queued message"
|
||||
accessibilityLabel={editLabel}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
|
||||
@@ -498,7 +532,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
|
||||
<Pressable
|
||||
onPress={handleSendNow}
|
||||
style={QUEUE_SEND_BUTTON_STYLE}
|
||||
accessibilityLabel="Send queued message now"
|
||||
accessibilityLabel={sendNowLabel}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ThemedArrowUp size={ICON_SIZE.sm} uniProps={iconAccentForegroundMapping} />
|
||||
@@ -523,6 +557,8 @@ interface ImageAttachmentPillProps {
|
||||
disabled: boolean;
|
||||
onOpen: (attachment: ComposerAttachment) => void;
|
||||
onRemove: (index: number) => void;
|
||||
openLabel: string;
|
||||
removeLabel: string;
|
||||
}
|
||||
|
||||
function ImageAttachmentPill({
|
||||
@@ -531,6 +567,8 @@ function ImageAttachmentPill({
|
||||
disabled,
|
||||
onOpen,
|
||||
onRemove,
|
||||
openLabel,
|
||||
removeLabel,
|
||||
}: ImageAttachmentPillProps) {
|
||||
const handleOpen = useCallback(() => {
|
||||
onOpen(attachment);
|
||||
@@ -543,8 +581,8 @@ function ImageAttachmentPill({
|
||||
testID="composer-image-attachment-pill"
|
||||
onOpen={handleOpen}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel="Open image attachment"
|
||||
removeAccessibilityLabel="Remove image attachment"
|
||||
openAccessibilityLabel={openLabel}
|
||||
removeAccessibilityLabel={removeLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ImageAttachmentThumbnail image={attachment.metadata} />
|
||||
@@ -558,6 +596,8 @@ interface GithubAttachmentPillProps {
|
||||
disabled: boolean;
|
||||
onOpen: (attachment: ComposerAttachment) => void;
|
||||
onRemove: (index: number) => void;
|
||||
openLabel: (kind: string, number: number) => string;
|
||||
removeLabel: (kind: string, number: number) => string;
|
||||
}
|
||||
|
||||
function GithubAttachmentPill({
|
||||
@@ -566,6 +606,8 @@ function GithubAttachmentPill({
|
||||
disabled,
|
||||
onOpen,
|
||||
onRemove,
|
||||
openLabel,
|
||||
removeLabel,
|
||||
}: GithubAttachmentPillProps) {
|
||||
const item = attachment.item;
|
||||
const kindLabel = item.kind === "pr" ? "PR" : "issue";
|
||||
@@ -580,8 +622,8 @@ function GithubAttachmentPill({
|
||||
testID="composer-github-attachment-pill"
|
||||
onOpen={handleOpen}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
|
||||
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
|
||||
openAccessibilityLabel={openLabel(kindLabel, item.number)}
|
||||
removeAccessibilityLabel={removeLabel(kindLabel, item.number)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={styles.githubPillBody}>
|
||||
@@ -693,8 +735,6 @@ interface ComposerProps {
|
||||
}
|
||||
|
||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
||||
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||
const StableMessageInput = memo(MessageInput);
|
||||
|
||||
function resolveContextWindowValues(
|
||||
@@ -714,6 +754,7 @@ interface ComposerCancelButtonProps {
|
||||
isConnected: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
agentInterruptKeys: ReturnType<typeof useShortcutKeys>;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function ComposerCancelButton({
|
||||
@@ -723,8 +764,11 @@ function ComposerCancelButton({
|
||||
isConnected,
|
||||
isCancellingAgent,
|
||||
agentInterruptKeys,
|
||||
t,
|
||||
}: ComposerCancelButtonProps) {
|
||||
const accessibilityLabel = isCancellingAgent ? "Canceling agent" : "Stop agent";
|
||||
const accessibilityLabel = isCancellingAgent
|
||||
? t("composer.cancel.cancelingAgent")
|
||||
: t("composer.cancel.stopAgent");
|
||||
const icon = isCancellingAgent ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
@@ -744,7 +788,7 @@ function ComposerCancelButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||
<Text style={styles.tooltipText}>{t("composer.cancel.interrupt")}</Text>
|
||||
{shortcutNode}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
@@ -777,6 +821,7 @@ interface ComposerVoiceModeButtonProps {
|
||||
state: PressableStateCallbackType & { hovered?: boolean },
|
||||
) => (object | undefined)[];
|
||||
voiceToggleKeys: ReturnType<typeof useShortcutKeys>;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
|
||||
@@ -819,6 +864,7 @@ function ComposerVoiceModeButton({
|
||||
isVoiceSwitching,
|
||||
realtimeVoiceButtonStyle,
|
||||
voiceToggleKeys,
|
||||
t,
|
||||
}: ComposerVoiceModeButtonProps) {
|
||||
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
|
||||
const renderTriggerContent = useCallback(
|
||||
@@ -836,7 +882,7 @@ function ComposerVoiceModeButton({
|
||||
<TooltipTrigger
|
||||
onPress={handleToggleRealtimeVoice}
|
||||
disabled={!isConnected || isVoiceSwitching}
|
||||
accessibilityLabel="Enable Voice mode"
|
||||
accessibilityLabel={t("composer.voice.enableVoiceMode")}
|
||||
accessibilityRole="button"
|
||||
style={realtimeVoiceButtonStyle}
|
||||
>
|
||||
@@ -844,7 +890,7 @@ function ComposerVoiceModeButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Voice mode</Text>
|
||||
<Text style={styles.tooltipText}>{t("composer.voice.voiceMode")}</Text>
|
||||
{shortcutNode}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
@@ -887,6 +933,7 @@ export function Composer({
|
||||
externalKeyboardShift,
|
||||
isCompactLayout: isCompactLayoutOverride,
|
||||
}: ComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const buttonIconSize = resolveComposerButtonIconSize();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -920,7 +967,7 @@ export function Composer({
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
|
||||
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t);
|
||||
const userInput = value;
|
||||
const setUserInput = onChangeText;
|
||||
const {
|
||||
@@ -1070,11 +1117,11 @@ export function Composer({
|
||||
return;
|
||||
}
|
||||
if (!sendAgentMessageRef.current) {
|
||||
throw new Error("Host is not connected");
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
await sendAgentMessageRef.current(agentIdRef.current, text, submitAttachments);
|
||||
},
|
||||
[cwd, onMessageSent],
|
||||
[cwd, onMessageSent, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1088,7 +1135,7 @@ export function Composer({
|
||||
sendAttachments: ComposerAttachment[],
|
||||
) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const stream: AgentStreamWriter = {
|
||||
getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id),
|
||||
@@ -1106,7 +1153,7 @@ export function Composer({
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
};
|
||||
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead]);
|
||||
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead, t]);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitMessageRef.current = onSubmitMessage;
|
||||
@@ -1181,6 +1228,7 @@ export function Composer({
|
||||
onSubmitError: (error) => {
|
||||
console.error("[AgentInput] Failed to send message:", error);
|
||||
},
|
||||
failedToSendMessage: t("composer.errors.failedToSend"),
|
||||
});
|
||||
completeSubmit({
|
||||
result,
|
||||
@@ -1198,6 +1246,7 @@ export function Composer({
|
||||
setUserInput,
|
||||
submitBehavior,
|
||||
submitMessage,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1376,12 +1425,13 @@ export function Composer({
|
||||
queue: queueWriter,
|
||||
submitMessage: ({ text, attachments: queuedAttachments }) =>
|
||||
submitMessage(text, queuedAttachments),
|
||||
failedToSendMessage: t("composer.errors.failedToSend"),
|
||||
});
|
||||
if (result.status === "failed") {
|
||||
setSendError(result.errorMessage);
|
||||
}
|
||||
},
|
||||
[agentId, queueWriter, submitMessage],
|
||||
[agentId, queueWriter, submitMessage, t],
|
||||
);
|
||||
|
||||
const handleQueue = useCallback(
|
||||
@@ -1433,6 +1483,7 @@ export function Composer({
|
||||
isConnected={isConnected}
|
||||
isCancellingAgent={isCancellingAgent}
|
||||
agentInterruptKeys={agentInterruptKeys}
|
||||
t={t}
|
||||
/>
|
||||
),
|
||||
[
|
||||
@@ -1445,6 +1496,7 @@ export function Composer({
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
isProcessing,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1463,6 +1515,7 @@ export function Composer({
|
||||
isVoiceSwitching={isVoiceSwitching}
|
||||
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
|
||||
voiceToggleKeys={voiceToggleKeys}
|
||||
t={t}
|
||||
cancelButton={cancelButton}
|
||||
/>
|
||||
),
|
||||
@@ -1479,6 +1532,7 @@ export function Composer({
|
||||
isVoiceModeForAgent,
|
||||
isVoiceSwitching,
|
||||
realtimeVoiceButtonStyle,
|
||||
t,
|
||||
voiceToggleKeys,
|
||||
],
|
||||
);
|
||||
@@ -1528,7 +1582,7 @@ export function Composer({
|
||||
() => [
|
||||
{
|
||||
id: "image",
|
||||
label: "Add image",
|
||||
label: t("composer.attachments.addImage"),
|
||||
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
|
||||
onSelect: () => {
|
||||
void handlePickImage();
|
||||
@@ -1536,14 +1590,14 @@ export function Composer({
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
label: "Add issue or PR",
|
||||
label: t("composer.attachments.addIssueOrPr"),
|
||||
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
|
||||
onSelect: () => {
|
||||
setIsGithubPickerOpen(true);
|
||||
},
|
||||
},
|
||||
],
|
||||
[handlePickImage],
|
||||
[handlePickImage, t],
|
||||
);
|
||||
|
||||
const handleToggleGithubItem = useCallback(
|
||||
@@ -1641,13 +1695,28 @@ export function Composer({
|
||||
isComposerLocked,
|
||||
handleOpenAttachment,
|
||||
handleRemoveAttachment,
|
||||
labels: {
|
||||
openImage: t("composer.attachments.openImage"),
|
||||
removeImage: t("composer.attachments.removeImage"),
|
||||
openGithub: (kind: string, number: number) =>
|
||||
t("composer.attachments.openGithub", { kind, number }),
|
||||
removeGithub: (kind: string, number: number) =>
|
||||
t("composer.attachments.removeGithub", { kind, number }),
|
||||
},
|
||||
}),
|
||||
[handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments],
|
||||
[handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t],
|
||||
);
|
||||
|
||||
const queueList = useMemo(
|
||||
() => renderQueueTrack({ queuedMessages, handleEditQueuedMessage, handleSendQueuedNow }),
|
||||
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages],
|
||||
() =>
|
||||
renderQueueTrack({
|
||||
queuedMessages,
|
||||
handleEditQueuedMessage,
|
||||
handleSendQueuedNow,
|
||||
editLabel: t("composer.attachments.editQueuedMessage"),
|
||||
sendNowLabel: t("composer.attachments.sendQueuedMessageNow"),
|
||||
}),
|
||||
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages, t],
|
||||
);
|
||||
|
||||
const messageInputContainerRef = useRef<View>(null);
|
||||
@@ -1660,8 +1729,8 @@ export function Composer({
|
||||
[sendError],
|
||||
);
|
||||
const githubEmptyText = githubSearchResultsQuery.isFetching
|
||||
? "Searching..."
|
||||
: "No results found.";
|
||||
? t("composer.github.searching")
|
||||
: t("composer.github.noResults");
|
||||
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
|
||||
|
||||
return (
|
||||
@@ -1732,8 +1801,8 @@ export function Composer({
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder="Search issues and PRs..."
|
||||
title="Attach issue or PR"
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
|
||||
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
|
||||
@@ -60,6 +61,12 @@ import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useComposerHeightMirror } from "./height-mirror";
|
||||
import {
|
||||
resolveSendTooltipLabel,
|
||||
resolveSubmitAccessibilityLabel,
|
||||
resolveVoiceAccessibilityLabel,
|
||||
resolveVoiceTooltipText,
|
||||
} from "./labels";
|
||||
import { computeCanStartDictation } from "./state";
|
||||
|
||||
export interface AttachmentMenuItem {
|
||||
@@ -142,7 +149,6 @@ const MIN_INPUT_HEIGHT_DESKTOP = 46;
|
||||
const DEFAULT_MAX_INPUT_HEIGHT = 160;
|
||||
const MAX_INPUT_VIEWPORT_RATIO = 0.5;
|
||||
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
|
||||
const ATTACHMENT_SHEET_HEADER: SheetHeader = { title: "Add attachment" };
|
||||
const ATTACHMENT_SHEET_SNAP_POINTS = ["34%", "45%"];
|
||||
|
||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
@@ -259,18 +265,24 @@ function AttachmentDropdown({
|
||||
attachButtonStyle,
|
||||
renderAttachButtonIcon,
|
||||
attachmentMenuItems,
|
||||
addAttachmentLabel,
|
||||
}: {
|
||||
isConnected: boolean;
|
||||
disabled: boolean;
|
||||
attachButtonStyle: React.ComponentProps<typeof DropdownMenuTrigger>["style"];
|
||||
renderAttachButtonIcon: (input: { hovered?: boolean }) => React.ReactElement;
|
||||
attachmentMenuItems: AttachmentMenuItem[];
|
||||
addAttachmentLabel: string;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
useDismissKeyboardOnOpen(isSheetOpen, isCompact);
|
||||
|
||||
const isButtonDisabled = !isConnected || disabled;
|
||||
const attachmentSheetHeader = useMemo<SheetHeader>(
|
||||
() => ({ title: addAttachmentLabel }),
|
||||
[addAttachmentLabel],
|
||||
);
|
||||
const handleOpenSheet = useCallback(() => {
|
||||
if (isButtonDisabled) return;
|
||||
setIsSheetOpen(true);
|
||||
@@ -306,7 +318,7 @@ function AttachmentDropdown({
|
||||
<>
|
||||
<Pressable
|
||||
disabled={isButtonDisabled}
|
||||
accessibilityLabel="Add attachment"
|
||||
accessibilityLabel={addAttachmentLabel}
|
||||
accessibilityRole="button"
|
||||
testID="message-input-attach-button"
|
||||
onPress={handleOpenSheet}
|
||||
@@ -315,7 +327,7 @@ function AttachmentDropdown({
|
||||
{renderMobileAttachButtonIcon}
|
||||
</Pressable>
|
||||
<AdaptiveModalSheet
|
||||
header={ATTACHMENT_SHEET_HEADER}
|
||||
header={attachmentSheetHeader}
|
||||
visible={isSheetOpen}
|
||||
onClose={handleCloseSheet}
|
||||
snapPoints={ATTACHMENT_SHEET_SNAP_POINTS}
|
||||
@@ -333,7 +345,7 @@ function AttachmentDropdown({
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger
|
||||
disabled={isButtonDisabled}
|
||||
accessibilityLabel="Add attachment"
|
||||
accessibilityLabel={addAttachmentLabel}
|
||||
accessibilityRole="button"
|
||||
testID="message-input-attach-button"
|
||||
style={attachButtonStyle}
|
||||
@@ -342,7 +354,7 @@ function AttachmentDropdown({
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Add attachment</Text>
|
||||
<Text style={styles.tooltipText}>{addAttachmentLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
@@ -429,49 +441,6 @@ function SendButtonContent({
|
||||
return <ThemedArrowUp size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
|
||||
}
|
||||
|
||||
function resolveSubmitAccessibilityLabel(input: {
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
canPressLoadingButton: boolean;
|
||||
defaultActionQueues: boolean;
|
||||
isAgentRunning: boolean;
|
||||
}): string {
|
||||
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
|
||||
if (input.canPressLoadingButton) return "Interrupt agent";
|
||||
if (input.defaultActionQueues) return "Queue message";
|
||||
if (input.isAgentRunning) return "Send and interrupt";
|
||||
return "Send message";
|
||||
}
|
||||
|
||||
function resolveVoiceAccessibilityLabel(input: {
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
isMuted: boolean;
|
||||
isDictating: boolean;
|
||||
}): string {
|
||||
if (input.isRealtimeVoiceForCurrentAgent) {
|
||||
return input.isMuted ? "Unmute Voice mode" : "Mute Voice mode";
|
||||
}
|
||||
if (input.isDictating) return "Stop dictation";
|
||||
return "Start dictation";
|
||||
}
|
||||
|
||||
function resolveVoiceTooltipText(input: {
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
isMuted: boolean;
|
||||
}): string {
|
||||
if (input.isRealtimeVoiceForCurrentAgent) {
|
||||
return input.isMuted ? "Unmute voice" : "Mute voice";
|
||||
}
|
||||
return "Dictation";
|
||||
}
|
||||
|
||||
function resolveSendTooltipLabel(input: {
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
defaultActionQueues: boolean;
|
||||
}): string {
|
||||
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
|
||||
return input.defaultActionQueues ? "Queue" : "Send";
|
||||
}
|
||||
|
||||
interface DesktopKeyPressContext {
|
||||
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined;
|
||||
submitOnEnter: boolean;
|
||||
@@ -749,14 +718,16 @@ function MessageInputOverlay({
|
||||
function FocusHint({
|
||||
visible,
|
||||
focusInputKeys,
|
||||
label,
|
||||
}: {
|
||||
visible: boolean;
|
||||
focusInputKeys: ShortcutChord | null | undefined;
|
||||
label: string;
|
||||
}) {
|
||||
if (!visible || !focusInputKeys) return null;
|
||||
if (!visible || !focusInputKeys || !label.trim()) return null;
|
||||
return (
|
||||
<Text style={styles.focusHintText} pointerEvents="none">
|
||||
{formatShortcut(focusInputKeys[0], getShortcutOs())} to focus
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -812,9 +783,8 @@ function SendButtonTooltip({
|
||||
isSubmitLoading,
|
||||
submitIcon,
|
||||
buttonIconSize,
|
||||
submitButtonAccessibilityLabel,
|
||||
defaultActionQueues,
|
||||
sendKeys,
|
||||
sendTooltipLabel,
|
||||
}: {
|
||||
shouldShow: boolean;
|
||||
canPressLoadingButton: boolean;
|
||||
@@ -826,9 +796,8 @@ function SendButtonTooltip({
|
||||
isSubmitLoading: boolean;
|
||||
submitIcon: "arrow" | "return";
|
||||
buttonIconSize: number;
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
defaultActionQueues: boolean;
|
||||
sendKeys: ShortcutChord | null | undefined;
|
||||
sendTooltipLabel: string;
|
||||
}) {
|
||||
if (!shouldShow) return null;
|
||||
return (
|
||||
@@ -847,10 +816,7 @@ function SendButtonTooltip({
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<SendTooltipBody
|
||||
label={resolveSendTooltipLabel({ submitButtonAccessibilityLabel, defaultActionQueues })}
|
||||
sendKeys={sendKeys}
|
||||
/>
|
||||
<SendTooltipBody label={sendTooltipLabel} sendKeys={sendKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -908,6 +874,7 @@ interface ToggleRealtimeVoiceContext {
|
||||
isAgentRunning: boolean;
|
||||
handleStopRealtimeVoice: () => Promise<unknown> | void;
|
||||
toast: { error: (msg: string) => void };
|
||||
interruptBeforeVoiceMessage: string;
|
||||
}
|
||||
|
||||
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
||||
@@ -920,7 +887,7 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
||||
return;
|
||||
}
|
||||
if (ctx.isAgentRunning) {
|
||||
ctx.toast.error("Interrupt the agent before starting voice mode");
|
||||
ctx.toast.error(ctx.interruptBeforeVoiceMessage);
|
||||
return;
|
||||
}
|
||||
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
|
||||
@@ -1199,7 +1166,7 @@ interface ResolvedMessageInputProps {
|
||||
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
|
||||
client: DaemonClient | null;
|
||||
isReadyForDictation: boolean | undefined;
|
||||
placeholder: string;
|
||||
placeholder: string | undefined;
|
||||
autoFocus: boolean;
|
||||
autoFocusKey: string | undefined;
|
||||
disabled: boolean;
|
||||
@@ -1239,7 +1206,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
||||
onAddImages: props.onAddImages,
|
||||
client: props.client,
|
||||
isReadyForDictation: props.isReadyForDictation,
|
||||
placeholder: props.placeholder ?? "Message...",
|
||||
placeholder: props.placeholder,
|
||||
autoFocus: props.autoFocus ?? false,
|
||||
autoFocusKey: props.autoFocusKey,
|
||||
disabled: props.disabled ?? false,
|
||||
@@ -1308,6 +1275,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
inputWrapperStyle,
|
||||
attachmentSlot,
|
||||
} = resolveMessageInputProps(props);
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const maxInputHeight = resolveMaxInputHeight(windowHeight);
|
||||
@@ -1564,12 +1532,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isAgentRunning,
|
||||
handleStopRealtimeVoice,
|
||||
toast,
|
||||
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
|
||||
});
|
||||
}, [
|
||||
disabled,
|
||||
handleStopRealtimeVoice,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
t,
|
||||
toast,
|
||||
voice,
|
||||
voiceAgentId,
|
||||
@@ -1747,17 +1717,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
canPressLoadingButton,
|
||||
defaultActionQueues,
|
||||
isAgentRunning,
|
||||
t,
|
||||
});
|
||||
|
||||
const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
isMuted: Boolean(voice?.isMuted),
|
||||
isDictating,
|
||||
t,
|
||||
});
|
||||
|
||||
const voiceTooltipText = resolveVoiceTooltipText({
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
isMuted: Boolean(voice?.isMuted),
|
||||
t,
|
||||
});
|
||||
|
||||
const sendTooltipLabel = resolveSendTooltipLabel({
|
||||
submitButtonAccessibilityLabel,
|
||||
defaultActionQueues,
|
||||
t,
|
||||
});
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
@@ -1854,9 +1833,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
ref={textInputRef}
|
||||
value={value}
|
||||
onChangeText={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
placeholder={placeholder ?? t("composer.placeholders.fallback")}
|
||||
uniProps={textInputPlaceholderColorMapping}
|
||||
accessibilityLabel="Message agent..."
|
||||
accessibilityLabel={t("composer.input.accessibilityLabel")}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
style={textInputStyle}
|
||||
@@ -1872,6 +1851,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
<FocusHint
|
||||
visible={isWeb && isPaneFocused && !isInputFocused && !value}
|
||||
focusInputKeys={focusInputKeys}
|
||||
label={t("composer.input.focusHint", {
|
||||
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1885,6 +1867,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
attachButtonStyle={attachButtonStyle}
|
||||
renderAttachButtonIcon={renderAttachButtonIcon}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
addAttachmentLabel={t("composer.input.addAttachment")}
|
||||
/>
|
||||
{leftContent}
|
||||
</View>
|
||||
@@ -1915,9 +1898,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitIcon={submitIcon}
|
||||
buttonIconSize={buttonIconSize}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
defaultActionQueues={defaultActionQueues}
|
||||
sendKeys={sendKeys}
|
||||
sendTooltipLabel={sendTooltipLabel}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
137
packages/app/src/composer/input/labels.test.ts
Normal file
137
packages/app/src/composer/input/labels.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveSendTooltipLabel,
|
||||
resolveSubmitAccessibilityLabel,
|
||||
resolveVoiceAccessibilityLabel,
|
||||
resolveVoiceTooltipText,
|
||||
} from "./labels";
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
"composer.input.interruptAgent": "Interrupt agent",
|
||||
"composer.input.queueMessage": "Queue message",
|
||||
"composer.input.sendAndInterrupt": "Send and interrupt",
|
||||
"composer.input.sendMessage": "Send message",
|
||||
"composer.input.queue": "Queue",
|
||||
"composer.input.send": "Send",
|
||||
"composer.voice.unmuteVoiceMode": "Unmute Voice mode",
|
||||
"composer.voice.muteVoiceMode": "Mute Voice mode",
|
||||
"composer.voice.stopDictation": "Stop dictation",
|
||||
"composer.voice.startDictation": "Start dictation",
|
||||
"composer.voice.unmuteVoice": "Unmute voice",
|
||||
"composer.voice.muteVoice": "Mute voice",
|
||||
"composer.voice.dictation": "Dictation",
|
||||
};
|
||||
|
||||
const t = ((key: string) => translations[key] ?? key) as never;
|
||||
|
||||
describe("composer input labels", () => {
|
||||
it("resolves submit accessibility labels from translations", () => {
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: true,
|
||||
defaultActionQueues: false,
|
||||
isAgentRunning: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Interrupt agent");
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: true,
|
||||
isAgentRunning: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Queue message");
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: false,
|
||||
isAgentRunning: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Send and interrupt");
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: false,
|
||||
isAgentRunning: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Send message");
|
||||
});
|
||||
|
||||
it("keeps explicit submit labels untouched", () => {
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: "Run now",
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: false,
|
||||
isAgentRunning: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Run now");
|
||||
});
|
||||
|
||||
it("resolves voice labels from translations", () => {
|
||||
expect(
|
||||
resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent: true,
|
||||
isMuted: true,
|
||||
isDictating: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Unmute Voice mode");
|
||||
expect(
|
||||
resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent: true,
|
||||
isMuted: false,
|
||||
isDictating: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Mute Voice mode");
|
||||
expect(
|
||||
resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent: false,
|
||||
isMuted: false,
|
||||
isDictating: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Stop dictation");
|
||||
expect(
|
||||
resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent: false,
|
||||
isMuted: false,
|
||||
isDictating: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Start dictation");
|
||||
});
|
||||
|
||||
it("resolves tooltip labels from translations", () => {
|
||||
expect(
|
||||
resolveVoiceTooltipText({
|
||||
isRealtimeVoiceForCurrentAgent: false,
|
||||
isMuted: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Dictation");
|
||||
expect(
|
||||
resolveSendTooltipLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
defaultActionQueues: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Queue");
|
||||
expect(
|
||||
resolveSendTooltipLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
defaultActionQueues: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Send");
|
||||
});
|
||||
});
|
||||
54
packages/app/src/composer/input/labels.ts
Normal file
54
packages/app/src/composer/input/labels.ts
Normal 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");
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
|
||||
|
||||
export interface AgentInputSubmitActionInput<TAttachment> {
|
||||
@@ -17,6 +19,7 @@ export interface AgentInputSubmitActionInput<TAttachment> {
|
||||
setSendError: (message: string | null) => void;
|
||||
setIsProcessing: (isProcessing: boolean) => void;
|
||||
onSubmitError?: (error: unknown) => void;
|
||||
failedToSendMessage?: string;
|
||||
}
|
||||
|
||||
export async function submitAgentInput<TAttachment>(
|
||||
@@ -67,7 +70,11 @@ export async function submitAgentInput<TAttachment>(
|
||||
input.setUserInput(trimmedMessage);
|
||||
input.setAttachments(attachments);
|
||||
}
|
||||
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
|
||||
input.setSendError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: (input.failedToSendMessage ?? i18n.t("composer.errors.failedToSend")),
|
||||
);
|
||||
input.setIsProcessing(false);
|
||||
return "failed";
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRef, ReactNode, useCallback, useEffect } from "react";
|
||||
import { Buffer } from "buffer";
|
||||
import { AppState } from "react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
@@ -440,6 +441,7 @@ export function SessionProvider(props: SessionProviderProps) {
|
||||
}
|
||||
|
||||
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
|
||||
const { t } = useTranslation();
|
||||
const voiceRuntime = useVoiceRuntimeOptional();
|
||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -908,25 +910,25 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
serverId,
|
||||
setVoiceMode: async (enabled, agentId) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
throw new Error(t("common.errors.daemonUnavailable"));
|
||||
}
|
||||
await client.setVoiceMode(enabled, agentId);
|
||||
},
|
||||
sendVoiceAudioChunk: async (audioData, mimeType) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
throw new Error(t("common.errors.daemonUnavailable"));
|
||||
}
|
||||
await client.sendVoiceAudioChunk(audioData, mimeType);
|
||||
},
|
||||
audioPlayed: async (chunkId) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
throw new Error(t("common.errors.daemonUnavailable"));
|
||||
}
|
||||
await client.audioPlayed(chunkId);
|
||||
},
|
||||
abortRequest: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
throw new Error(t("common.errors.daemonUnavailable"));
|
||||
}
|
||||
await client.abortRequest();
|
||||
},
|
||||
@@ -935,7 +937,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
},
|
||||
});
|
||||
return () => unregister?.();
|
||||
}, [client, serverId, setIsPlayingAudio, voiceRuntime]);
|
||||
}, [client, serverId, setIsPlayingAudio, t, voiceRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
voiceRuntime?.updateSessionConnection(serverId, isConnected);
|
||||
|
||||
@@ -10,6 +10,12 @@ export interface DesktopPermissionRowProps {
|
||||
title: string;
|
||||
status: DesktopPermissionStatus | null;
|
||||
isRequesting: boolean;
|
||||
labels: {
|
||||
granted: string;
|
||||
request: string;
|
||||
requesting: string;
|
||||
busyExtraAction: (label: string) => string;
|
||||
};
|
||||
showBorder?: boolean;
|
||||
onRequest: () => void;
|
||||
extraActionLabel?: string;
|
||||
@@ -22,6 +28,7 @@ export function DesktopPermissionRow({
|
||||
title,
|
||||
status,
|
||||
isRequesting,
|
||||
labels,
|
||||
showBorder,
|
||||
onRequest,
|
||||
extraActionLabel,
|
||||
@@ -54,7 +61,7 @@ export function DesktopPermissionRow({
|
||||
<View style={styles.permissionGrantedActions}>
|
||||
<View style={styles.permissionStatusPill}>
|
||||
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.permissionStatusText}>Granted</Text>
|
||||
<Text style={styles.permissionStatusText}>{labels.granted}</Text>
|
||||
</View>
|
||||
{extraActionLabel && onExtraAction ? (
|
||||
<Button
|
||||
@@ -63,13 +70,13 @@ export function DesktopPermissionRow({
|
||||
onPress={onExtraAction}
|
||||
disabled={isExtraActionDisabled || isExtraActionBusy}
|
||||
>
|
||||
{isExtraActionBusy ? `${extraActionLabel}...` : extraActionLabel}
|
||||
{isExtraActionBusy ? labels.busyExtraAction(extraActionLabel) : extraActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
|
||||
{isRequesting ? "Requesting..." : "Request"}
|
||||
{isRequesting ? labels.requesting : labels.request}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowDetail ? (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
@@ -9,6 +10,7 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
|
||||
export function DesktopPermissionsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
isDesktopApp,
|
||||
@@ -59,12 +61,22 @@ export function DesktopPermissionsSection() {
|
||||
leftIcon={refreshIcon}
|
||||
onPress={handleRefreshPress}
|
||||
disabled={isBusy}
|
||||
accessibilityLabel="Refresh desktop permissions"
|
||||
accessibilityLabel={t("settings.permissions.refreshAccessibility")}
|
||||
>
|
||||
{isRefreshing ? "Refreshing..." : "Refresh"}
|
||||
{isRefreshing ? t("settings.permissions.refreshing") : t("settings.permissions.refresh")}
|
||||
</Button>
|
||||
),
|
||||
[refreshIcon, handleRefreshPress, isBusy, isRefreshing],
|
||||
[refreshIcon, handleRefreshPress, isBusy, isRefreshing, t],
|
||||
);
|
||||
|
||||
const permissionLabels = useMemo(
|
||||
() => ({
|
||||
granted: t("settings.permissions.actions.granted"),
|
||||
request: t("settings.permissions.actions.request"),
|
||||
requesting: t("settings.permissions.actions.requesting"),
|
||||
busyExtraAction: (label: string) => t("settings.permissions.actions.busySuffix", { label }),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!isDesktopApp) {
|
||||
@@ -72,25 +84,27 @@ export function DesktopPermissionsSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title="Permissions" trailing={refreshButton}>
|
||||
<SettingsSection title={t("settings.permissions.title")} trailing={refreshButton}>
|
||||
<View style={settingsStyles.card}>
|
||||
<DesktopPermissionRow
|
||||
title="Notifications"
|
||||
title={t("settings.permissions.notifications")}
|
||||
status={snapshot?.notifications ?? null}
|
||||
isRequesting={requestingPermission === "notifications"}
|
||||
onRequest={handleRequestNotifications}
|
||||
extraActionLabel="Test"
|
||||
labels={permissionLabels}
|
||||
extraActionLabel={t("settings.permissions.test")}
|
||||
isExtraActionBusy={isSendingTestNotification}
|
||||
isExtraActionDisabled={!notificationsGranted || isBusy}
|
||||
onExtraAction={handleSendTestNotification}
|
||||
/>
|
||||
{testNotificationError ? <Text style={errorTextStyle}>{testNotificationError}</Text> : null}
|
||||
<DesktopPermissionRow
|
||||
title="Microphone"
|
||||
title={t("settings.permissions.microphone")}
|
||||
showBorder
|
||||
status={snapshot?.microphone ?? null}
|
||||
isRequesting={requestingPermission === "microphone"}
|
||||
onRequest={handleRequestMicrophone}
|
||||
labels={permissionLabels}
|
||||
/>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { type ReactElement, useCallback, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
@@ -41,6 +42,7 @@ function useKeepRunningAfterQuitToggle(args: {
|
||||
}
|
||||
|
||||
function useDaemonCliStatusModal() {
|
||||
const { t } = useTranslation();
|
||||
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
||||
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
||||
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
||||
@@ -52,12 +54,12 @@ function useDaemonCliStatusModal() {
|
||||
setIsCliStatusModalOpen(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setCliStatusOutput(`Failed to fetch daemon status: ${message}`);
|
||||
setCliStatusOutput(t("desktop.daemon.fullStatus.fetchFailed", { message }));
|
||||
setIsCliStatusModalOpen(true);
|
||||
} finally {
|
||||
setIsLoadingCliStatus(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleCopyCliStatus = useCallback(() => {
|
||||
if (!cliStatusOutput) {
|
||||
@@ -65,13 +67,13 @@ function useDaemonCliStatusModal() {
|
||||
}
|
||||
void Clipboard.setStringAsync(cliStatusOutput)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Status copied to clipboard.");
|
||||
Alert.alert(t("common.states.copied"), t("desktop.daemon.fullStatus.copied"));
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy daemon status", error);
|
||||
});
|
||||
}, [cliStatusOutput]);
|
||||
}, [cliStatusOutput, t]);
|
||||
|
||||
const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []);
|
||||
|
||||
@@ -86,6 +88,7 @@ function useDaemonCliStatusModal() {
|
||||
}
|
||||
|
||||
function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) {
|
||||
const { t } = useTranslation();
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||
|
||||
const handleCopyLogPath = useCallback(() => {
|
||||
@@ -96,14 +99,14 @@ function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) {
|
||||
|
||||
void Clipboard.setStringAsync(logPath)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Log path copied.");
|
||||
Alert.alert(t("common.states.copied"), t("desktop.daemon.logs.copied"));
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy log path", error);
|
||||
Alert.alert("Error", "Unable to copy log path.");
|
||||
Alert.alert(t("common.errors.error"), t("desktop.daemon.logs.copyFailed"));
|
||||
});
|
||||
}, [daemonLogs?.logPath]);
|
||||
}, [daemonLogs?.logPath, t]);
|
||||
|
||||
const handleOpenLogs = useCallback(() => {
|
||||
if (!daemonLogs) {
|
||||
@@ -124,18 +127,23 @@ interface DaemonLogsModalProps {
|
||||
}
|
||||
|
||||
function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("desktop.daemon.logs.modalTitle") }), [t]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={DAEMON_LOGS_HEADER}
|
||||
header={header}
|
||||
testID="managed-daemon-logs-dialog"
|
||||
snapPoints={LOGS_MODAL_SNAP_POINTS}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable"}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{daemonLogs?.logPath ?? t("desktop.daemon.logs.unavailable")}
|
||||
</Text>
|
||||
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
|
||||
{daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"}
|
||||
{daemonLogs?.contents?.length ? daemonLogs.contents : t("desktop.daemon.logs.empty")}
|
||||
</Text>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
@@ -155,11 +163,17 @@ function DaemonCliStatusModal({
|
||||
cliStatusOutput,
|
||||
onCopy,
|
||||
}: DaemonCliStatusModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("desktop.daemon.fullStatus.modalTitle") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={DAEMON_STATUS_HEADER}
|
||||
header={header}
|
||||
testID="daemon-cli-status-dialog"
|
||||
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
|
||||
>
|
||||
@@ -169,10 +183,10 @@ function DaemonCliStatusModal({
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={onClose}>
|
||||
Close
|
||||
{t("common.actions.close")}
|
||||
</Button>
|
||||
<Button size="sm" onPress={onCopy}>
|
||||
Copy
|
||||
{t("common.actions.copy")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -200,6 +214,7 @@ interface DaemonInfoCardProps {
|
||||
}
|
||||
|
||||
function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
daemonStatusStateText,
|
||||
daemonStatusDetailText,
|
||||
@@ -223,8 +238,8 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.status.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.status.builtInOnly")}</Text>
|
||||
</View>
|
||||
<View style={styles.statusValueGroup}>
|
||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||
@@ -233,39 +248,39 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Manage built-in daemon</Text>
|
||||
<Text style={settingsStyles.rowHint}>Let Paseo start and stop the built-in daemon</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.management.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.management.hint")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={!isDaemonManagementPaused}
|
||||
onValueChange={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
accessibilityLabel="Manage built-in daemon"
|
||||
accessibilityLabel={t("desktop.daemon.management.title")}
|
||||
/>
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Keep daemon running after quit</Text>
|
||||
<Text style={settingsStyles.rowHint}>Daemon keeps running when you quit Paseo</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.keepRunning.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.keepRunning.hint")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={keepRunningAfterQuit}
|
||||
onValueChange={handleToggleKeepRunningAfterQuit}
|
||||
disabled={isUpdatingKeepRunningAfterQuit}
|
||||
accessibilityLabel="Keep daemon running after quit"
|
||||
accessibilityLabel={t("desktop.daemon.keepRunning.title")}
|
||||
/>
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.logs.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{daemonLogs?.logPath ?? "Log path unavailable"}
|
||||
{daemonLogs?.logPath ?? t("desktop.daemon.logs.unavailable")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{daemonLogs?.logPath ? (
|
||||
<Button variant="outline" size="sm" leftIcon={copyIcon} onPress={handleCopyLogPath}>
|
||||
Copy path
|
||||
{t("desktop.daemon.logs.copyPath")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -275,16 +290,14 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
onPress={handleOpenLogs}
|
||||
disabled={!daemonLogs}
|
||||
>
|
||||
Open logs
|
||||
{t("desktop.daemon.logs.open")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Runs `paseo daemon status` and shows the output
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.fullStatus.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.fullStatus.hint")}</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -293,7 +306,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
onPress={handleRunCliStatus}
|
||||
disabled={isLoadingCliStatus}
|
||||
>
|
||||
{isLoadingCliStatus ? "Loading..." : "View status"}
|
||||
{isLoadingCliStatus ? t("common.states.loading") : t("desktop.daemon.fullStatus.view")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -301,6 +314,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
}
|
||||
|
||||
export function LocalDaemonSection() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const appVersion = resolveAppVersion();
|
||||
@@ -318,8 +332,13 @@ export function LocalDaemonSection() {
|
||||
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
|
||||
const daemonStatusStateText =
|
||||
statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running");
|
||||
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`;
|
||||
statusError ??
|
||||
(daemonStatus?.status === "running"
|
||||
? t("desktop.daemon.status.running")
|
||||
: t("desktop.daemon.status.notRunning"));
|
||||
const daemonStatusDetailText = t("desktop.daemon.status.pid", {
|
||||
pid: daemonStatus?.pid ? daemonStatus.pid : "—",
|
||||
});
|
||||
const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon;
|
||||
|
||||
const { isUpdating: isUpdatingDaemonManagement, toggle: handleToggleDaemonManagement } =
|
||||
@@ -382,12 +401,12 @@ export function LocalDaemonSection() {
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={handleOpenAdvancedSettings}
|
||||
accessibilityLabel="Open advanced daemon settings"
|
||||
accessibilityLabel={t("desktop.daemon.openAdvancedSettings")}
|
||||
>
|
||||
Advanced settings
|
||||
{t("desktop.daemon.advancedSettings")}
|
||||
</Button>
|
||||
),
|
||||
[advancedSettingsIcon, handleOpenAdvancedSettings],
|
||||
[advancedSettingsIcon, handleOpenAdvancedSettings, t],
|
||||
);
|
||||
|
||||
if (!showSection) {
|
||||
@@ -396,7 +415,7 @@ export function LocalDaemonSection() {
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Daemon"
|
||||
title={t("desktop.daemon.title")}
|
||||
trailing={advancedSettingsButton}
|
||||
testID="host-page-daemon-lifecycle-card"
|
||||
>
|
||||
@@ -427,11 +446,7 @@ export function LocalDaemonSection() {
|
||||
|
||||
{daemonVersionMismatch ? (
|
||||
<View style={styles.warningCard}>
|
||||
<Text style={styles.warningText}>
|
||||
{
|
||||
"App and daemon versions don't match. Update both to the same version for the best experience."
|
||||
}
|
||||
</Text>
|
||||
<Text style={styles.warningText}>{t("desktop.daemon.versionMismatch")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
@@ -513,5 +528,3 @@ const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard];
|
||||
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||
const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"];
|
||||
const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"];
|
||||
const DAEMON_LOGS_HEADER: SheetHeader = { title: "Daemon logs" };
|
||||
const DAEMON_STATUS_HEADER: SheetHeader = { title: "Daemon status" };
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -8,16 +10,34 @@ import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { shouldUseDesktopDaemon, type SkillsStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
import {
|
||||
shouldUseDesktopDaemon,
|
||||
type SkillOp,
|
||||
type SkillsStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
|
||||
|
||||
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||
const UNINSTALL_MESSAGE =
|
||||
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
|
||||
|
||||
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
|
||||
const OP_KIND_LABEL_KEY: Record<SkillOp["kind"], string> = {
|
||||
add: "settings.integrations.operations.add",
|
||||
update: "settings.integrations.operations.update",
|
||||
delete: "settings.integrations.operations.delete",
|
||||
};
|
||||
|
||||
function formatUpdateMessage(ops: readonly SkillOp[], t: TFunction): string {
|
||||
const sorted = [...ops].sort((a, b) => {
|
||||
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
|
||||
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted.map((op) => `${t(OP_KIND_LABEL_KEY[op.kind])} ${op.name}`).join("\n");
|
||||
}
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const {
|
||||
@@ -56,20 +76,30 @@ export function IntegrationsSection() {
|
||||
|
||||
const handleUpdateSkills = useCallback(async () => {
|
||||
if (isSkillsWorking) return;
|
||||
const ops = skillsStatus?.ops ?? [];
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("settings.integrations.skills.updateTitle"),
|
||||
message:
|
||||
ops.length > 0
|
||||
? formatUpdateMessage(ops, t)
|
||||
: t("settings.integrations.skills.updateFallback"),
|
||||
confirmLabel: t("settings.integrations.actions.update"),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await updateSkills();
|
||||
}, [isSkillsWorking, updateSkills]);
|
||||
}, [isSkillsWorking, skillsStatus, t, updateSkills]);
|
||||
|
||||
const handleUninstallSkills = useCallback(async () => {
|
||||
if (isSkillsWorking) return;
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Uninstall Paseo skills?",
|
||||
message: UNINSTALL_MESSAGE,
|
||||
confirmLabel: "Uninstall",
|
||||
title: t("settings.integrations.skills.uninstallTitle"),
|
||||
message: t("settings.integrations.skills.uninstallMessage"),
|
||||
confirmLabel: t("settings.integrations.actions.uninstall"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await uninstallSkills();
|
||||
}, [isSkillsWorking, uninstallSkills]);
|
||||
}, [isSkillsWorking, t, uninstallSkills]);
|
||||
|
||||
const handleOpenCliDocs = useCallback(() => {
|
||||
void openExternalUrl(CLI_DOCS_URL);
|
||||
@@ -94,9 +124,9 @@ export function IntegrationsSection() {
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={handleOpenCliDocs}
|
||||
accessibilityLabel="Open CLI documentation"
|
||||
accessibilityLabel={t("settings.integrations.docs.openCli")}
|
||||
>
|
||||
CLI docs
|
||||
{t("settings.integrations.docs.cli")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -105,13 +135,13 @@ export function IntegrationsSection() {
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={handleOpenSkillsDocs}
|
||||
accessibilityLabel="Open skills documentation"
|
||||
accessibilityLabel={t("settings.integrations.docs.openSkills")}
|
||||
>
|
||||
Skills docs
|
||||
{t("settings.integrations.docs.skills")}
|
||||
</Button>
|
||||
</View>
|
||||
),
|
||||
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs],
|
||||
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs, t],
|
||||
);
|
||||
|
||||
if (!showSection) {
|
||||
@@ -121,20 +151,24 @@ export function IntegrationsSection() {
|
||||
const skillsState = skillsStatus?.state ?? null;
|
||||
|
||||
return (
|
||||
<SettingsSection title="Integrations" trailing={trailing}>
|
||||
<SettingsSection title={t("settings.integrations.title")} trailing={trailing}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>Command line</Text>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.integrations.commandLine.title")}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>Control and script agents from your terminal</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.integrations.commandLine.description")}
|
||||
</Text>
|
||||
</View>
|
||||
{cliStatus?.installed ? (
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
@@ -143,7 +177,9 @@ export function IntegrationsSection() {
|
||||
onPress={handleInstallCli}
|
||||
disabled={isInstallingCli}
|
||||
>
|
||||
{isInstallingCli ? "Installing..." : "Install"}
|
||||
{isInstallingCli
|
||||
? t("settings.integrations.actions.installing")
|
||||
: t("settings.integrations.actions.install")}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
@@ -151,12 +187,12 @@ export function IntegrationsSection() {
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.integrations.skills.title")}</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{skillsState === "drift"
|
||||
? "Update available"
|
||||
: "Teach your agents to orchestrate through the CLI"}
|
||||
? t("settings.integrations.skills.updateAvailable")
|
||||
: t("settings.integrations.skills.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<SkillsActions
|
||||
@@ -181,6 +217,7 @@ interface SkillsActionsProps {
|
||||
}
|
||||
|
||||
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (state === "up-to-date") {
|
||||
@@ -188,10 +225,10 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
|
||||
<View style={styles.actionsRow}>
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||
Uninstall
|
||||
{t("settings.integrations.actions.uninstall")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -201,10 +238,12 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
|
||||
return (
|
||||
<View style={styles.actionsRow}>
|
||||
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
|
||||
{isWorking ? "Working..." : "Update"}
|
||||
{isWorking
|
||||
? t("settings.integrations.actions.working")
|
||||
: t("settings.integrations.actions.update")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||
Uninstall
|
||||
{t("settings.integrations.actions.uninstall")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -212,7 +251,9 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
|
||||
{isWorking ? "Installing..." : "Install"}
|
||||
{isWorking
|
||||
? t("settings.integrations.actions.installing")
|
||||
: t("settings.integrations.actions.install")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
|
||||
|
||||
@@ -8,12 +10,17 @@ export interface PairDeviceModalProps {
|
||||
}
|
||||
|
||||
const SNAP_POINTS: string[] = ["82%", "94%"];
|
||||
const PAIR_DEVICE_HEADER: SheetHeader = { title: "Pair a device" };
|
||||
|
||||
export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("settings.integrations.pairDevices.rowTitle") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={PAIR_DEVICE_HEADER}
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={SNAP_POINTS}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, Image, Text, TextInput, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as QRCode from "qrcode";
|
||||
@@ -21,18 +22,21 @@ function resolvePairingViewState(args: {
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
data: { url?: string | null; relayEnabled?: boolean } | undefined;
|
||||
labels: {
|
||||
failedToLoadOffer: string;
|
||||
relayDisabled: string;
|
||||
unavailable: string;
|
||||
};
|
||||
}): PairingViewState {
|
||||
if (args.isPending) return { tag: "loading" };
|
||||
if (args.isError) {
|
||||
const message =
|
||||
args.error instanceof Error ? args.error.message : "Failed to load pairing offer.";
|
||||
args.error instanceof Error ? args.error.message : args.labels.failedToLoadOffer;
|
||||
return { tag: "error", message };
|
||||
}
|
||||
if (!args.data?.url) {
|
||||
const message =
|
||||
args.data?.relayEnabled === false
|
||||
? "Relay is not enabled. Enable relay to pair a device."
|
||||
: "Pairing offer unavailable.";
|
||||
args.data?.relayEnabled === false ? args.labels.relayDisabled : args.labels.unavailable;
|
||||
return { tag: "unavailable", message };
|
||||
}
|
||||
return { tag: "ready", url: args.data.url };
|
||||
@@ -40,6 +44,7 @@ function resolvePairingViewState(args: {
|
||||
|
||||
export function PairDeviceSection() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -96,6 +101,17 @@ export function PairDeviceSection() {
|
||||
),
|
||||
[copied, theme.iconSize.sm, theme.colors.accent, theme.colors.foreground],
|
||||
);
|
||||
const bodyLabels = useMemo(
|
||||
() => ({
|
||||
loadingOffer: t("pairing.device.loadingOffer"),
|
||||
hint: t("pairing.device.hint"),
|
||||
qrUnavailable: t("pairing.device.qrUnavailable"),
|
||||
retry: t("pairing.device.retry"),
|
||||
copy: t("pairing.device.copy"),
|
||||
copied: t("pairing.device.copied"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!showSection) return null;
|
||||
|
||||
@@ -104,6 +120,11 @@ export function PairDeviceSection() {
|
||||
isError: pairingQuery.isError,
|
||||
error: pairingQuery.error,
|
||||
data: pairingQuery.data,
|
||||
labels: {
|
||||
failedToLoadOffer: t("pairing.device.failedToLoadOffer"),
|
||||
relayDisabled: t("pairing.device.relayDisabled"),
|
||||
unavailable: t("pairing.device.unavailable"),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -119,6 +140,7 @@ export function PairDeviceSection() {
|
||||
copied={copied}
|
||||
handleRefetch={handleRefetch}
|
||||
handleCopyPress={handleCopyPress}
|
||||
labels={bodyLabels}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -135,6 +157,14 @@ interface PairDeviceBodyProps {
|
||||
copied: boolean;
|
||||
handleRefetch: () => void;
|
||||
handleCopyPress: () => void;
|
||||
labels: {
|
||||
loadingOffer: string;
|
||||
hint: string;
|
||||
qrUnavailable: string;
|
||||
retry: string;
|
||||
copy: string;
|
||||
copied: string;
|
||||
};
|
||||
}
|
||||
|
||||
function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
@@ -148,13 +178,14 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
copied,
|
||||
handleRefetch,
|
||||
handleCopyPress,
|
||||
labels,
|
||||
} = props;
|
||||
|
||||
if (viewState.tag === "loading") {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.hint}>Loading pairing offer…</Text>
|
||||
<Text style={styles.hint}>{labels.loadingOffer}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -164,7 +195,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.hint}>{viewState.message}</Text>
|
||||
<Button variant="outline" size="sm" leftIcon={retryIcon} onPress={handleRefetch}>
|
||||
Retry
|
||||
{labels.retry}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -172,11 +203,13 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
|
||||
return (
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.hint}>
|
||||
Scan this QR code with Paseo on your phone, or copy the link below.
|
||||
</Text>
|
||||
<Text style={styles.hint}>{labels.hint}</Text>
|
||||
<View style={styles.qrContainer}>
|
||||
<PairDeviceQrContent qrImageSource={qrImageSource} qrQuery={qrQuery} />
|
||||
<PairDeviceQrContent
|
||||
qrImageSource={qrImageSource}
|
||||
qrQuery={qrQuery}
|
||||
unavailableLabel={labels.qrUnavailable}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.linkRow}>
|
||||
<View style={styles.inputWrapper}>
|
||||
@@ -189,7 +222,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
/>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" leftIcon={copyButtonIcon} onPress={handleCopyPress}>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
{copied ? labels.copied : labels.copy}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -199,12 +232,13 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
function PairDeviceQrContent(props: {
|
||||
qrImageSource: { uri: string } | null;
|
||||
qrQuery: { isError: boolean };
|
||||
unavailableLabel: string;
|
||||
}) {
|
||||
if (props.qrImageSource) {
|
||||
return <Image source={props.qrImageSource} style={styles.qrImage} resizeMode="contain" />;
|
||||
}
|
||||
if (props.qrQuery.isError) {
|
||||
return <Text style={styles.hint}>QR code unavailable.</Text>;
|
||||
return <Text style={styles.hint}>{props.unavailableLabel}</Text>;
|
||||
}
|
||||
return <ActivityIndicator size="small" />;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export class DaemonConnectionRegistrationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@@ -34,19 +36,18 @@ export function getDaemonManagementErrorPresentation(
|
||||
|
||||
if (presentationError instanceof DaemonConnectionRegistrationError) {
|
||||
return {
|
||||
message:
|
||||
"Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.",
|
||||
message: i18n.t("desktop.daemon.management.registrationFailed"),
|
||||
refreshStatus: true,
|
||||
};
|
||||
}
|
||||
if (wasManagingDaemon) {
|
||||
return {
|
||||
message: "Built-in daemon management was paused, but Paseo could not stop the daemon.",
|
||||
message: i18n.t("desktop.daemon.management.pausedStopFailed"),
|
||||
refreshStatus: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: "Unable to update built-in daemon management.",
|
||||
message: i18n.t("desktop.daemon.management.updateFailed"),
|
||||
refreshStatus: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type DesktopDaemonStatus,
|
||||
startDesktopDaemon,
|
||||
@@ -38,6 +39,7 @@ interface UseBuiltInDaemonManagementResult {
|
||||
export function useBuiltInDaemonManagement(
|
||||
input: UseBuiltInDaemonManagementInput,
|
||||
): UseBuiltInDaemonManagementResult {
|
||||
const { t } = useTranslation();
|
||||
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
|
||||
const reportError = useDesktopIpcErrorReporter();
|
||||
const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation<
|
||||
@@ -50,11 +52,10 @@ export function useBuiltInDaemonManagement(
|
||||
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
|
||||
confirm: () =>
|
||||
confirmDialog({
|
||||
title: "Pause built-in daemon",
|
||||
message:
|
||||
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
|
||||
confirmLabel: "Pause and stop",
|
||||
cancelLabel: "Cancel",
|
||||
title: t("desktop.daemon.management.pauseTitle"),
|
||||
message: t("desktop.daemon.management.pauseMessage"),
|
||||
confirmLabel: t("desktop.daemon.management.pauseAndStop"),
|
||||
cancelLabel: t("common.actions.cancel"),
|
||||
destructive: true,
|
||||
}),
|
||||
persistSettings: (next) => updateSettings(next) as Promise<void>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getDesktopDaemonLogs,
|
||||
getDesktopDaemonStatus,
|
||||
@@ -17,6 +18,7 @@ interface DaemonStatusData {
|
||||
}
|
||||
|
||||
export function useDaemonStatus() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
|
||||
@@ -33,7 +35,7 @@ export function useDaemonStatus() {
|
||||
});
|
||||
useDesktopIpcQueryErrorToast({
|
||||
error: query.error,
|
||||
message: "Unable to load desktop daemon status.",
|
||||
message: t("desktop.daemon.loadFailed"),
|
||||
logLabel: "[DesktopDaemon] Failed to load daemon status",
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { useCliInstall, useSkillsStatus } from "./use-install-status";
|
||||
|
||||
const toast = vi.hoisted(() => ({
|
||||
@@ -54,6 +55,7 @@ describe("useCliInstall", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
void i18n.changeLanguage("en");
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -89,6 +91,28 @@ describe("useCliInstall", () => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Unable to install the Paseo CLI.");
|
||||
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install CLI", error);
|
||||
});
|
||||
|
||||
it("uses the active language for CLI install errors", async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
const error = new Error("Missing IPC handler");
|
||||
desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: false });
|
||||
desktopDaemon.installCli.mockRejectedValue(error);
|
||||
const { result } = renderDesktopHook(() => useCliInstall());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ installed: false });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.install();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe(error);
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("无法安装 Paseo CLI。");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSkillsStatus", () => {
|
||||
@@ -97,6 +121,7 @@ describe("useSkillsStatus", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
void i18n.changeLanguage("en");
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getCliInstallStatus,
|
||||
getSkillsStatus,
|
||||
@@ -29,6 +30,7 @@ interface DesktopInstallHookResult {
|
||||
}
|
||||
|
||||
export function useCliInstall(): DesktopInstallHookResult {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const reportError = useDesktopIpcErrorReporter();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
@@ -42,7 +44,7 @@ export function useCliInstall(): DesktopInstallHookResult {
|
||||
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
|
||||
useDesktopIpcQueryErrorToast({
|
||||
error: statusQuery.error,
|
||||
message: "Unable to check CLI install status.",
|
||||
message: t("desktop.integrations.cli.statusFailed"),
|
||||
logLabel: "[Integrations] Failed to load CLI status",
|
||||
});
|
||||
|
||||
@@ -51,7 +53,7 @@ export function useCliInstall(): DesktopInstallHookResult {
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to install the Paseo CLI.",
|
||||
message: t("desktop.integrations.cli.installFailed"),
|
||||
logLabel: "[Integrations] Failed to install CLI",
|
||||
});
|
||||
},
|
||||
@@ -88,6 +90,7 @@ export interface SkillsStatusHookResult {
|
||||
}
|
||||
|
||||
export function useSkillsStatus(): SkillsStatusHookResult {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const reportError = useDesktopIpcErrorReporter();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
@@ -101,7 +104,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
|
||||
const { data: status, error: statusError, isLoading, refetch } = statusQuery;
|
||||
useDesktopIpcQueryErrorToast({
|
||||
error: statusQuery.error,
|
||||
message: "Unable to check orchestration skills status.",
|
||||
message: t("desktop.integrations.skills.statusFailed"),
|
||||
logLabel: "[Integrations] Failed to load skills status",
|
||||
});
|
||||
|
||||
@@ -117,7 +120,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to install orchestration skills.",
|
||||
message: t("desktop.integrations.skills.installFailed"),
|
||||
logLabel: "[Integrations] Failed to install skills",
|
||||
});
|
||||
},
|
||||
@@ -129,7 +132,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to update orchestration skills.",
|
||||
message: t("desktop.integrations.skills.updateFailed"),
|
||||
logLabel: "[Integrations] Failed to update skills",
|
||||
});
|
||||
},
|
||||
@@ -141,7 +144,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to uninstall orchestration skills.",
|
||||
message: t("desktop.integrations.skills.uninstallFailed"),
|
||||
logLabel: "[Integrations] Failed to uninstall skills",
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { DesktopHostBridge } from "@/desktop/host";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
createDesktopPermissions,
|
||||
type DesktopPermissionEnvironment,
|
||||
@@ -180,4 +181,28 @@ describe("desktop-permissions", () => {
|
||||
|
||||
expect(result.state).toBe("denied");
|
||||
});
|
||||
|
||||
it("uses the active app language for local status details", async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
try {
|
||||
const permissions = createDesktopPermissions(
|
||||
fakeEnvironment({
|
||||
notification: { permission: "granted" },
|
||||
navigator: {
|
||||
permissions: {
|
||||
query: vi.fn(async () => ({ state: "prompt" })),
|
||||
},
|
||||
mediaDevices: { getUserMedia: vi.fn() },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshot = await permissions.getDesktopPermissionSnapshot();
|
||||
|
||||
expect(snapshot.notifications.detail).toBe("系统已允许通知。");
|
||||
expect(snapshot.microphone.detail).toBe("麦克风权限尚未授予。");
|
||||
} finally {
|
||||
await i18n.changeLanguage("en");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type DesktopHostBridge, getDesktopHost } from "@/desktop/host";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export type DesktopPermissionKind = "notifications" | "microphone";
|
||||
|
||||
@@ -97,24 +98,26 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
|
||||
if (permission === "granted") {
|
||||
return status({
|
||||
state: "granted",
|
||||
detail: "Notifications are allowed by the OS.",
|
||||
detail: i18n.t("desktop.permissions.notifications.allowed"),
|
||||
});
|
||||
}
|
||||
if (permission === "denied") {
|
||||
return status({
|
||||
state: "denied",
|
||||
detail: "Notifications are denied in system settings.",
|
||||
detail: i18n.t("desktop.permissions.notifications.denied"),
|
||||
});
|
||||
}
|
||||
if (permission === "default") {
|
||||
return status({
|
||||
state: "prompt",
|
||||
detail: "Notifications have not been granted yet.",
|
||||
detail: i18n.t("desktop.permissions.notifications.notGranted"),
|
||||
});
|
||||
}
|
||||
return status({
|
||||
state: "unknown",
|
||||
detail: `Unexpected notification permission state: ${permission}`,
|
||||
detail: i18n.t("desktop.permissions.notifications.unexpectedState", {
|
||||
state: permission,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,7 +130,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (!env.isWeb) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop notification status is only available on web runtime.",
|
||||
detail: i18n.t("desktop.permissions.notifications.webOnly"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,8 +141,8 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
return status({
|
||||
state: supported ? "granted" : "unavailable",
|
||||
detail: supported
|
||||
? "Desktop notifications are supported."
|
||||
: "Desktop notifications are not supported on this platform.",
|
||||
? i18n.t("desktop.permissions.notifications.supported")
|
||||
: i18n.t("desktop.permissions.notifications.unsupported"),
|
||||
});
|
||||
} catch {
|
||||
// Fall through to web API check
|
||||
@@ -153,7 +156,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Web Notification API is unavailable in this environment.",
|
||||
detail: i18n.t("desktop.permissions.notifications.apiUnavailable"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -161,7 +164,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (!env.isWeb) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop microphone status is only available on web runtime.",
|
||||
detail: i18n.t("desktop.permissions.microphone.webOnly"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,7 +172,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (!webNavigator) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Navigator is unavailable in this environment.",
|
||||
detail: i18n.t("desktop.permissions.microphone.navigatorUnavailable"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -180,36 +183,39 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (result?.state === "granted") {
|
||||
return status({
|
||||
state: "granted",
|
||||
detail: "Microphone access is granted.",
|
||||
detail: i18n.t("desktop.permissions.microphone.granted"),
|
||||
});
|
||||
}
|
||||
if (result?.state === "denied") {
|
||||
return status({
|
||||
state: "denied",
|
||||
detail: "Microphone access is denied in system settings.",
|
||||
detail: i18n.t("desktop.permissions.microphone.denied"),
|
||||
});
|
||||
}
|
||||
if (result?.state === "prompt") {
|
||||
return status({
|
||||
state: "prompt",
|
||||
detail: "Microphone permission has not been granted yet.",
|
||||
detail: i18n.t("desktop.permissions.microphone.notGranted"),
|
||||
});
|
||||
}
|
||||
return status({
|
||||
state: "unknown",
|
||||
detail: `Unexpected microphone permission state: ${result?.state ?? "unknown"}`,
|
||||
detail: i18n.t("desktop.permissions.microphone.unexpectedState", {
|
||||
state: result?.state ?? "unknown",
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isPermissionsQueryRuntimeUnsupported(error)) {
|
||||
return status({
|
||||
state: "unknown",
|
||||
detail:
|
||||
"Microphone status API is unavailable in this runtime. Use Request to check access.",
|
||||
detail: i18n.t("desktop.permissions.microphone.statusApiUnavailable"),
|
||||
});
|
||||
}
|
||||
return status({
|
||||
state: "unknown",
|
||||
detail: `Failed to query microphone status: ${getErrorMessage(error)}`,
|
||||
detail: i18n.t("desktop.permissions.microphone.queryFailed", {
|
||||
message: getErrorMessage(error),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -217,13 +223,13 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Microphone capture is unavailable in this environment.",
|
||||
detail: i18n.t("desktop.permissions.microphone.captureUnavailable"),
|
||||
});
|
||||
}
|
||||
|
||||
return status({
|
||||
state: "unknown",
|
||||
detail: "Permission status API is unavailable. Use Request to check access.",
|
||||
detail: i18n.t("desktop.permissions.microphone.permissionApiUnavailable"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -231,7 +237,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (!env.isWeb) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop notification requests are only available on web runtime.",
|
||||
detail: i18n.t("desktop.permissions.notifications.requestsWebOnly"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -246,14 +252,16 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
} catch (error) {
|
||||
return status({
|
||||
state: "unknown",
|
||||
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
|
||||
detail: i18n.t("desktop.permissions.notifications.requestFailed", {
|
||||
message: getErrorMessage(error),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Web Notification API requestPermission() is unavailable.",
|
||||
detail: i18n.t("desktop.permissions.notifications.requestUnavailable"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,7 +269,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (!env.isWeb) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop microphone requests are only available on web runtime.",
|
||||
detail: i18n.t("desktop.permissions.microphone.requestsWebOnly"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -269,7 +277,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Microphone capture API is unavailable in this environment.",
|
||||
detail: i18n.t("desktop.permissions.microphone.captureApiUnavailable"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -287,18 +295,20 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
|
||||
if (errorName === "NotAllowedError" || errorName === "PermissionDeniedError") {
|
||||
return status({
|
||||
state: "denied",
|
||||
detail: "Microphone permission was denied by the user or system.",
|
||||
detail: i18n.t("desktop.permissions.microphone.requestDenied"),
|
||||
});
|
||||
}
|
||||
if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "No microphone device was found.",
|
||||
detail: i18n.t("desktop.permissions.microphone.noDevice"),
|
||||
});
|
||||
}
|
||||
return status({
|
||||
state: "unknown",
|
||||
detail: `Failed to request microphone permission: ${getErrorMessage(error)}`,
|
||||
detail: i18n.t("desktop.permissions.microphone.requestFailed", {
|
||||
message: getErrorMessage(error),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getDesktopPermissionSnapshot,
|
||||
requestDesktopPermission,
|
||||
@@ -20,17 +21,8 @@ export interface UseDesktopPermissionsReturn {
|
||||
sendTestNotification: () => Promise<void>;
|
||||
}
|
||||
|
||||
const EMPTY_NOTIFICATION_STATUS = {
|
||||
state: "unknown" as const,
|
||||
detail: "Notification status has not been checked yet.",
|
||||
};
|
||||
|
||||
const EMPTY_MICROPHONE_STATUS = {
|
||||
state: "unknown" as const,
|
||||
detail: "Microphone status has not been checked yet.",
|
||||
};
|
||||
|
||||
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
const { t } = useTranslation();
|
||||
const isDesktopApp = shouldShowDesktopPermissionSection();
|
||||
const isMountedRef = useRef(true);
|
||||
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
|
||||
@@ -83,8 +75,14 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
setSnapshot((previous) => {
|
||||
const base: DesktopPermissionSnapshot = previous ?? {
|
||||
checkedAt: Date.now(),
|
||||
notifications: EMPTY_NOTIFICATION_STATUS,
|
||||
microphone: EMPTY_MICROPHONE_STATUS,
|
||||
notifications: {
|
||||
state: "unknown",
|
||||
detail: t("desktop.permissions.empty.notifications"),
|
||||
},
|
||||
microphone: {
|
||||
state: "unknown",
|
||||
detail: t("desktop.permissions.empty.microphone"),
|
||||
},
|
||||
};
|
||||
|
||||
if (kind === "notifications") {
|
||||
@@ -110,7 +108,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
await refreshPermissions();
|
||||
}
|
||||
},
|
||||
[isDesktopApp, refreshPermissions],
|
||||
[isDesktopApp, refreshPermissions, t],
|
||||
);
|
||||
|
||||
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
|
||||
@@ -124,22 +122,20 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
setTestNotificationError(null);
|
||||
try {
|
||||
const sent = await sendOsNotification({
|
||||
title: "Paseo notification test",
|
||||
body: "If you can see this, desktop notifications work.",
|
||||
title: t("desktop.permissions.testNotification.title"),
|
||||
body: t("desktop.permissions.testNotification.body"),
|
||||
});
|
||||
if (!sent) {
|
||||
setTestNotificationError(
|
||||
"Notification was not delivered. Check System Settings > Notifications.",
|
||||
);
|
||||
setTestNotificationError(t("desktop.permissions.testNotification.notDelivered"));
|
||||
}
|
||||
} catch {
|
||||
setTestNotificationError("Failed to send notification.");
|
||||
setTestNotificationError(t("desktop.permissions.testNotification.failed"));
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsSendingTestNotification(false);
|
||||
}
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
}, [isDesktopApp, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopApp) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user