fix(feedback): localize the Help menu entry and show send progress

Send Feedback... shipped in en.json only, so es/ja/ko/zh rendered it in English
directly above a localized Report Crash... — steering non-English users back into
the crash channel the entry exists to drain. The catalog gate only warns on
missing keys, so a menu-namespace parity test now guards the OS menu bar.

Send to Orca had no visible busy state: the in-flight ref already dropped repeat
clicks, but with main's 10s submit deadline nothing changed on screen, so users
kept clicking a button that silently ignored them. Surface the pending flag and
mirror the Retry Relay spinner/disabled treatment.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-04 00:52:21 -07:00
co-authored by Orca
parent 7a5e705fe7
commit 692a60bc15
12 changed files with 126 additions and 29 deletions
@@ -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: '担当者',
@@ -107,7 +107,7 @@ export function MobileHeroPairingStep({
}): React.JSX.Element {
const copyPairingCodeRef = useRef<HTMLButtonElement | null>(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}
/>
@@ -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 ? <Loader2 className="animate-spin" /> : null}
{sendingDiagnostics
? translate(
'auto.components.mobile.MobileRelayMintFailureNotice.sendingDiagnostics',
'Sending…'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.sendDiagnostics',
'Send to Orca'
)}
</Button>
) : null}
</div>
@@ -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 (
<button type="button" onClick={() => void send({ failure, preferredConnectionMode: 'relay' })}>
<button
type="button"
onClick={() => void relayDiagnostics.send({ failure, preferredConnectionMode: 'relay' })}
>
send
</button>
)
}
function RelayNoticeHarness(): React.JSX.Element {
const relayDiagnostics = useSendRelayMintFailureFeedback()
return (
<MobileRelayMintFailureNotice
failure={failure}
onUseLan={vi.fn()}
onRetry={vi.fn()}
onCopyDiagnostics={vi.fn()}
onSendDiagnostics={() =>
void relayDiagnostics.send({ failure, preferredConnectionMode: 'relay' })
}
sendingDiagnostics={relayDiagnostics.sending}
/>
)
}
function stubFeedbackApi(
viewer: () => Promise<null>,
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<null>((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(<SendButton />)
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(<RelayNoticeHarness />)
await user.click(screen.getByRole('button', { name: 'Send to Orca' }))
const sending = await screen.findByRole<HTMLButtonElement>('button', { name: 'Sending…' })
expect(sending.disabled).toBe(true)
resolveSubmit({ ok: true })
const sent = await screen.findByRole<HTMLButtonElement>('button', { name: 'Send to Orca' })
expect(sent.disabled).toBe(false)
})
})
@@ -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<void> {
// 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<void>
/** Why: main allows the submit 10s, so the button must show progress or users re-click. */
sending: boolean
}
return useCallback(async (args: RelayMintFailureFeedbackArgs): Promise<void> => {
/** 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<void> => {
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 }
}
@@ -288,7 +288,7 @@ export function MobilePane(): React.JSX.Element {
]
)
const sendRelayDiagnostics = useSendRelayMintFailureFeedback()
const relayDiagnostics = useSendRelayMintFailureFeedback()
const copyRelayDiagnostics = useCallback(async (): Promise<void> => {
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}
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -64,7 +64,8 @@
"openWorktreePalette": "Abrir paleta de worktrees",
"window": "Ventana",
"help": "Ayuda",
"paste": "Pegar"
"paste": "Pegar",
"sendFeedback": "Enviar comentarios..."
},
"tray": {
"openOrca": "Abrir Orca",
+2 -1
View File
@@ -64,7 +64,8 @@
"openWorktreePalette": "ワークツリーパレットを開く",
"window": "ウィンドウ",
"help": "ヘルプ",
"paste": "貼り付け"
"paste": "貼り付け",
"sendFeedback": "フィードバックを送信..."
},
"tray": {
"openOrca": "Orcaを開く",
+2 -1
View File
@@ -64,7 +64,8 @@
"openWorktreePalette": "워크트리 팔레트 열기",
"window": "창",
"help": "도움말",
"paste": "붙여넣기"
"paste": "붙여넣기",
"sendFeedback": "피드백 보내기..."
},
"tray": {
"openOrca": "Orca 열기",
+2 -1
View File
@@ -64,7 +64,8 @@
"openWorktreePalette": "打开工作树面板",
"window": "窗口",
"help": "帮助",
"paste": "粘贴"
"paste": "粘贴",
"sendFeedback": "发送反馈..."
},
"tray": {
"openOrca": "打开 Orca",
@@ -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<string, string> = 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)
}
})
})