diff --git a/config/scripts/locale-value-overrides.mjs b/config/scripts/locale-value-overrides.mjs index e237bca76b0..b286c3de05c 100644 --- a/config/scripts/locale-value-overrides.mjs +++ b/config/scripts/locale-value-overrides.mjs @@ -5,6 +5,7 @@ import { ZH_VALUE_OVERRIDES } from './locale-zh-value-overrides.mjs' export const LOCALE_VALUE_OVERRIDES = { es: { 'Explore Orca': 'Explorar Orca', + 'Send Feedback...': 'Enviar comentarios...', 'OpenCode Go': 'OpenCode Go', 'Open in Cursor': 'Abrir en Cursor', 'Local project, Git repo, or folder with many repos': @@ -60,6 +61,7 @@ export const LOCALE_VALUE_OVERRIDES = { 'Search...': '검색...', 'Saving...': '저장 중...', 'Report Crash...': '크래시 신고...', + 'Send Feedback...': '피드백 보내기...', Mobile: '모바일', Checks: '검사', Assignees: '담당자', @@ -220,6 +222,7 @@ export const LOCALE_VALUE_OVERRIDES = { 'Search...': '搜索...', 'Saving...': '保存中...', 'Report Crash...': '报告崩溃...', + 'Send Feedback...': '发送反馈...', Mobile: '移动端', Checks: '检查项', Assignees: '负责人', @@ -428,6 +431,7 @@ export const LOCALE_VALUE_OVERRIDES = { 'Search...': '検索...', 'Saving...': '保存中...', 'Report Crash...': 'クラッシュを報告...', + 'Send Feedback...': 'フィードバックを送信...', Mobile: 'モバイル', Checks: 'チェック', Assignees: '担当者', diff --git a/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx b/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx index c83f2893f7d..941fa87935e 100644 --- a/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx +++ b/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx @@ -107,7 +107,7 @@ export function MobileHeroPairingStep({ }): React.JSX.Element { const copyPairingCodeRef = useRef(null) const pairingWasReadyRef = useRef(pairingUrl != null && !pairLoading) - const sendRelayDiagnostics = useSendRelayMintFailureFeedback() + const relayDiagnostics = useSendRelayMintFailureFeedback() const emptyQrMessage = !pairLoading && pairQrDataUrl == null ? emptyPairingQrMessage({ @@ -164,11 +164,12 @@ export function MobileHeroPairingStep({ onRetry={onRetryRelay} onCopyDiagnostics={onCopyRelayDiagnostics} onSendDiagnostics={() => - void sendRelayDiagnostics({ + void relayDiagnostics.send({ failure: relayMintFailure, preferredConnectionMode: connectionMode }) } + sendingDiagnostics={relayDiagnostics.sending} compact busy={pairLoading} /> diff --git a/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx b/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx index 00ceae1347b..17643b4acb9 100644 --- a/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx +++ b/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx @@ -11,6 +11,7 @@ export function MobileRelayMintFailureNotice({ onRetry, onCopyDiagnostics, onSendDiagnostics, + sendingDiagnostics = false, className, compact = false, busy = false @@ -21,6 +22,7 @@ export function MobileRelayMintFailureNotice({ onCopyDiagnostics: () => void /** Why: without a direct send, users pasted this JSON into the crash dialog. */ onSendDiagnostics?: () => void + sendingDiagnostics?: boolean className?: string compact?: boolean busy?: boolean @@ -131,11 +133,19 @@ export function MobileRelayMintFailureNotice({ size={compact ? 'xs' : 'sm'} variant="ghost" onClick={onSendDiagnostics} + disabled={sendingDiagnostics} + className="w-28" > - {translate( - 'auto.components.mobile.MobileRelayMintFailureNotice.sendDiagnostics', - 'Send to Orca' - )} + {sendingDiagnostics ? : null} + {sendingDiagnostics + ? translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.sendingDiagnostics', + 'Sending…' + ) + : translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.sendDiagnostics', + 'Send to Orca' + )} ) : null} diff --git a/src/renderer/src/components/mobile/relay-mint-failure-feedback.test.tsx b/src/renderer/src/components/mobile/relay-mint-failure-feedback.test.tsx index 4dcc36258ce..ca31bc741ca 100644 --- a/src/renderer/src/components/mobile/relay-mint-failure-feedback.test.tsx +++ b/src/renderer/src/components/mobile/relay-mint-failure-feedback.test.tsx @@ -3,6 +3,7 @@ import { cleanup, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileRelayMintFailureNotice } from './mobile-relay-mint-failure-notice' import { useSendRelayMintFailureFeedback } from './relay-mint-failure-feedback' import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' @@ -17,14 +18,40 @@ const failure: MobileRelayMintFailure = { } function SendButton(): React.JSX.Element { - const send = useSendRelayMintFailureFeedback() + const relayDiagnostics = useSendRelayMintFailureFeedback() return ( - ) } +function RelayNoticeHarness(): React.JSX.Element { + const relayDiagnostics = useSendRelayMintFailureFeedback() + return ( + + void relayDiagnostics.send({ failure, preferredConnectionMode: 'relay' }) + } + sendingDiagnostics={relayDiagnostics.sending} + /> + ) +} + +function stubFeedbackApi( + viewer: () => Promise, + submit: () => Promise<{ ok: true } | { ok: false; error: string }> +): void { + vi.stubGlobal('window', Object.assign(window, { api: { gh: { viewer }, feedback: { submit } } })) +} + afterEach(() => { cleanup() vi.unstubAllGlobals() @@ -36,10 +63,7 @@ describe('useSendRelayMintFailureFeedback', () => { let resolveViewer: (value: null) => void = () => {} const viewer = vi.fn(() => new Promise((resolve) => (resolveViewer = resolve))) const submit = vi.fn(async () => ({ ok: true as const })) - vi.stubGlobal( - 'window', - Object.assign(window, { api: { gh: { viewer }, feedback: { submit } } }) - ) + stubFeedbackApi(viewer, submit) render() const button = screen.getByRole('button', { name: 'send' }) @@ -51,4 +75,22 @@ describe('useSendRelayMintFailureFeedback', () => { resolveViewer(null) await vi.waitFor(() => expect(submit).toHaveBeenCalledTimes(1)) }) + + it('disables Send to Orca and shows progress until the submit settles', async () => { + const user = userEvent.setup() + let resolveSubmit: (value: { ok: true }) => void = () => {} + const viewer = vi.fn(async () => null) + const submit = vi.fn(() => new Promise<{ ok: true }>((resolve) => (resolveSubmit = resolve))) + stubFeedbackApi(viewer, submit) + + render() + await user.click(screen.getByRole('button', { name: 'Send to Orca' })) + + const sending = await screen.findByRole('button', { name: 'Sending…' }) + expect(sending.disabled).toBe(true) + + resolveSubmit({ ok: true }) + const sent = await screen.findByRole('button', { name: 'Send to Orca' }) + expect(sent.disabled).toBe(false) + }) }) diff --git a/src/renderer/src/components/mobile/relay-mint-failure-feedback.ts b/src/renderer/src/components/mobile/relay-mint-failure-feedback.ts index c66babb4c1c..964ee60e42f 100644 --- a/src/renderer/src/components/mobile/relay-mint-failure-feedback.ts +++ b/src/renderer/src/components/mobile/relay-mint-failure-feedback.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react' +import { useCallback, useRef, useState } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' @@ -26,20 +26,26 @@ export function buildRelayMintFailureFeedback(args: RelayMintFailureFeedbackArgs ].join('\n') } -/** Why: without a direct send, users pasted these diagnostics into the crash dialog. */ -export function useSendRelayMintFailureFeedback(): ( - args: RelayMintFailureFeedbackArgs -) => Promise { - // Why: the button has no busy state and the send is a `gh` subprocess plus an - // HTTPS post, so repeat clicks would file duplicate reports and queue extra - // spawns behind the shared gh concurrency limit. - const inFlightRef = useRef(false) +export type RelayMintFailureFeedbackSender = { + send: (args: RelayMintFailureFeedbackArgs) => Promise + /** Why: main allows the submit 10s, so the button must show progress or users re-click. */ + sending: boolean +} - return useCallback(async (args: RelayMintFailureFeedbackArgs): Promise => { +/** Why: without a direct send, users pasted these diagnostics into the crash dialog. */ +export function useSendRelayMintFailureFeedback(): RelayMintFailureFeedbackSender { + // Why: a `gh` subprocess plus an HTTPS post, so repeat clicks would file duplicate + // reports and queue extra spawns behind the shared gh concurrency limit. The ref + // rejects clicks landing before React has flushed `sending` to the disabled button. + const inFlightRef = useRef(false) + const [sending, setSending] = useState(false) + + const send = useCallback(async (args: RelayMintFailureFeedbackArgs): Promise => { if (inFlightRef.current) { return } inFlightRef.current = true + setSending(true) try { // Why: identity is best-effort; a missing gh session must not block the report. const viewer = await window.api.gh.viewer().catch(() => null) @@ -68,6 +74,9 @@ export function useSendRelayMintFailureFeedback(): ( console.error('Failed to send relay pairing diagnostics:', error) } finally { inFlightRef.current = false + setSending(false) } }, []) + + return { send, sending } } diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index db8bbe8131b..3b91bb9f348 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -288,7 +288,7 @@ export function MobilePane(): React.JSX.Element { ] ) - const sendRelayDiagnostics = useSendRelayMintFailureFeedback() + const relayDiagnostics = useSendRelayMintFailureFeedback() const copyRelayDiagnostics = useCallback(async (): Promise => { if (relayMintFailure == null) { @@ -421,11 +421,12 @@ export function MobilePane(): React.JSX.Element { onRetry={() => void generateQR({ rotate: true })} onCopyDiagnostics={() => void copyRelayDiagnostics()} onSendDiagnostics={() => - void sendRelayDiagnostics({ + void relayDiagnostics.send({ failure: relayMintFailure, preferredConnectionMode: connectionMode }) } + sendingDiagnostics={relayDiagnostics.sending} busy={loading} /> ) : null} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1c8c9e10e46..051b53b252d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12086,7 +12086,8 @@ "copyDiagnostics": "Copy diagnostics", "sendDiagnostics": "Send to Orca", "diagnosticsSent": "Diagnostics sent to Orca", - "diagnosticsSendFailed": "Failed to send diagnostics" + "diagnosticsSendFailed": "Failed to send diagnostics", + "sendingDiagnostics": "Sending…" } }, "gitlab": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 14dd169539e..916fc8cde45 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -64,7 +64,8 @@ "openWorktreePalette": "Abrir paleta de worktrees", "window": "Ventana", "help": "Ayuda", - "paste": "Pegar" + "paste": "Pegar", + "sendFeedback": "Enviar comentarios..." }, "tray": { "openOrca": "Abrir Orca", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9ec2f14a80d..1789c553d93 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -64,7 +64,8 @@ "openWorktreePalette": "ワークツリーパレットを開く", "window": "ウィンドウ", "help": "ヘルプ", - "paste": "貼り付け" + "paste": "貼り付け", + "sendFeedback": "フィードバックを送信..." }, "tray": { "openOrca": "Orcaを開く", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 152e6a771dc..3d4902b865b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -64,7 +64,8 @@ "openWorktreePalette": "워크트리 팔레트 열기", "window": "창", "help": "도움말", - "paste": "붙여넣기" + "paste": "붙여넣기", + "sendFeedback": "피드백 보내기..." }, "tray": { "openOrca": "Orca 열기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7f9907ab181..5e3263e8d15 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -64,7 +64,8 @@ "openWorktreePalette": "打开工作树面板", "window": "窗口", "help": "帮助", - "paste": "粘贴" + "paste": "粘贴", + "sendFeedback": "发送反馈..." }, "tray": { "openOrca": "打开 Orca", diff --git a/src/renderer/src/i18n/os-menu-locale-parity.test.ts b/src/renderer/src/i18n/os-menu-locale-parity.test.ts new file mode 100644 index 00000000000..b172b624eda --- /dev/null +++ b/src/renderer/src/i18n/os-menu-locale-parity.test.ts @@ -0,0 +1,25 @@ +/** + * The `menu.*` namespace backs the OS menu bar, which every supported locale + * currently covers in full. A missing key there is invisible: translateMain() + * silently serves the English fallback next to localized siblings, and the + * catalog gate only warns because unrelated namespaces have known gaps. + */ +import { describe, expect, it } from 'vitest' +import en from './locales/en.json' +import es from './locales/es.json' +import ja from './locales/ja.json' +import ko from './locales/ko.json' +import zh from './locales/zh.json' + +const catalogs = { es, ja, ko, zh } + +describe('OS menu locale parity', () => { + it.each(Object.entries(catalogs))('%s translates every menu item', (_code, catalog) => { + const menu: Record = catalog.menu + for (const [key, english] of Object.entries(en.menu)) { + expect(menu[key], `menu.${key} missing from catalog`).toBeDefined() + expect(menu[key]?.trim()).not.toBe('') + expect(menu[key], `menu.${key} fell back to the English source`).not.toBe(english) + } + }) +})