mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
"openWorktreePalette": "Abrir paleta de worktrees",
|
||||
"window": "Ventana",
|
||||
"help": "Ayuda",
|
||||
"paste": "Pegar"
|
||||
"paste": "Pegar",
|
||||
"sendFeedback": "Enviar comentarios..."
|
||||
},
|
||||
"tray": {
|
||||
"openOrca": "Abrir Orca",
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
"openWorktreePalette": "ワークツリーパレットを開く",
|
||||
"window": "ウィンドウ",
|
||||
"help": "ヘルプ",
|
||||
"paste": "貼り付け"
|
||||
"paste": "貼り付け",
|
||||
"sendFeedback": "フィードバックを送信..."
|
||||
},
|
||||
"tray": {
|
||||
"openOrca": "Orcaを開く",
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
"openWorktreePalette": "워크트리 팔레트 열기",
|
||||
"window": "창",
|
||||
"help": "도움말",
|
||||
"paste": "붙여넣기"
|
||||
"paste": "붙여넣기",
|
||||
"sendFeedback": "피드백 보내기..."
|
||||
},
|
||||
"tray": {
|
||||
"openOrca": "Orca 열기",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user