From b2c26fc69c50e4c515cf8b1e9b8a5d7265dbab4f Mon Sep 17 00:00:00 2001 From: guantw Date: Mon, 21 Sep 2026 15:52:57 +0800 Subject: [PATCH 1/6] fix(review): preserve plan visibility when selecting all Stop select-all checkbox clicks from toggling the remediation list. Cover selection and title clicks with the real checkbox in expanded and collapsed states. --- .../RemediationSelectionPanel.test.tsx | 75 ++++++++++++------- .../action-bar/RemediationSelectionPanel.tsx | 24 +++--- 2 files changed, 60 insertions(+), 39 deletions(-) diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/RemediationSelectionPanel.test.tsx b/src/web-ui/src/flow_chat/deep-review/action-bar/RemediationSelectionPanel.test.tsx index 177613ebe5..e3d29249d1 100644 --- a/src/web-ui/src/flow_chat/deep-review/action-bar/RemediationSelectionPanel.test.tsx +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/RemediationSelectionPanel.test.tsx @@ -28,7 +28,7 @@ vi.mock('react-i18next', () => ({ }), })); -vi.mock('@openbitfun/ui', () => ({ +vi.mock('@openbitfun/ui', async () => ({ Icon: ({ name }: { name: string }) => , Button: ({ children, @@ -37,33 +37,7 @@ vi.mock('@openbitfun/ui', () => ({ children: React.ReactNode; disabled?: boolean; }) => , - Checkbox: ({ - checked, - className, - disabled, - indeterminate, - label, - onChange, - }: { - checked?: boolean; - className?: string; - disabled?: boolean; - indeterminate?: boolean; - label?: React.ReactNode; - onChange?: React.ChangeEventHandler; - }) => ( - - ), + Checkbox: (await vi.importActual("@openbitfun/ui")).Checkbox, Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, })); @@ -229,6 +203,51 @@ describeWithJsdom('RemediationSelectionPanel interactions', () => { vi.clearAllMocks(); }); + it.each([true, false])('keeps list visibility when selecting all (expanded=%s)', (expanded) => { + const onToggleAll = vi.fn(); + const onToggleList = vi.fn(); + function Panel() { + const [selected, setSelected] = React.useState(new Set()); + const [visible, setVisible] = React.useState(expanded); + return ( + { + onToggleAll(); + setSelected(selected.size ? new Set() : new Set(remediationItems().map((item) => item.id))); + }} + onToggleList={() => { + onToggleList(); + setVisible(!visible); + }} + /> + ); + } + mount(); + const header = container.querySelector('.deep-review-action-bar__remediation-toggle')!; + const checkbox = header.querySelector('input[type="checkbox"]')!; + const box = header.querySelector('[data-openbitfun-part="box"]')!; + + for (const target of [checkbox, box]) { + act(() => { target.click(); }); + expect(checkbox.checked).toBe(target === checkbox); + expect(Boolean(container.querySelector('.deep-review-action-bar__remediation-list'))).toBe(expanded); + expect(onToggleList).not.toHaveBeenCalled(); + } + expect(onToggleAll).toHaveBeenCalledTimes(2); + + act(() => { header.querySelector('.deep-review-action-bar__remediation-label')!.click(); }); + expect(onToggleList).toHaveBeenCalledTimes(1); + expect(Boolean(container.querySelector('.deep-review-action-bar__remediation-list'))).toBe(!expanded); + expect(onToggleAll).toHaveBeenCalledTimes(2); + }); + it('toggles a remediation group once when clicking the root checkbox', () => { const onToggleGroup = vi.fn(); diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/RemediationSelectionPanel.tsx b/src/web-ui/src/flow_chat/deep-review/action-bar/RemediationSelectionPanel.tsx index b2fc7bb909..ab5e6fd671 100644 --- a/src/web-ui/src/flow_chat/deep-review/action-bar/RemediationSelectionPanel.tsx +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/RemediationSelectionPanel.tsx @@ -93,17 +93,19 @@ export const RemediationSelectionPanel: React.FC className="deep-review-action-bar__remediation-toggle" onClick={onToggleList} > - 0} - onChange={() => { - if (!selectionDisabled) { - onToggleAll(); - } - }} - disabled={selectionDisabled || totalCount === 0} - size="sm" - /> + event.stopPropagation()}> + 0} + onChange={() => { + if (!selectionDisabled) { + onToggleAll(); + } + }} + disabled={selectionDisabled || totalCount === 0} + size="sm" + /> + {t('toolCards.codeReview.remediationActions.selectionCount', { selected: selectedCount, From 8c5fde06a059b9baa08e0f5735396f78c2d90b20 Mon Sep 17 00:00:00 2001 From: guantw Date: Mon, 21 Sep 2026 15:53:09 +0800 Subject: [PATCH 2/6] fix(review): keep export controls visible during review Show copy and save controls while a review is running. Disable report actions until report data arrives, then enable the existing export flow. --- .../action-bar/DeepReviewActionBar.tsx | 1 + .../action-bar/ReviewActionHeader.test.tsx | 7 ++--- .../action-bar/ReviewActionHeader.tsx | 4 ++- .../CodeReviewReportExportActions.test.tsx | 27 +++++++++++++++++++ .../CodeReviewReportExportActions.tsx | 11 +++++--- 5 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/DeepReviewActionBar.tsx b/src/web-ui/src/flow_chat/deep-review/action-bar/DeepReviewActionBar.tsx index fca1864b4f..8fba66cfb0 100644 --- a/src/web-ui/src/flow_chat/deep-review/action-bar/DeepReviewActionBar.tsx +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/DeepReviewActionBar.tsx @@ -926,6 +926,7 @@ export const ReviewActionBar: React.FC = ({ childSessionId onTouchMove={stopNestedScrollPropagation} > { expect(html).toContain('aria-label="Minimize"'); }); - it('keeps only compact export actions in the top-right controls', () => { + it.each([null, { summary: { recommended_action: 'request_changes' } }])('keeps compact export actions while running with report data %j', (reviewData) => { const Icon = () => phase icon; renderToStaticMarkup( , diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.tsx b/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.tsx index bddb434a55..058bc13161 100644 --- a/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.tsx +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.tsx @@ -6,6 +6,7 @@ type ExportableReviewData = React.ComponentProps = ({ reviewData, + isReviewRunning = false, PhaseIcon, phaseIconClass, phaseTitle, @@ -29,7 +31,7 @@ export const ReviewActionHeader: React.FC = ({ }) => ( <>
- {reviewData && ( + {(reviewData || isReviewRunning) && ( { delete (window as Window & { __TAURI__?: unknown }).__TAURI__; }); + it('keeps pending export controls visible and enables them when a report arrives', async () => { + (window as Window & { __TAURI__?: unknown }).__TAURI__ = {}; + vi.mocked(save).mockResolvedValue('/review.md'); + const container = document.createElement('div'); + const root = createRoot(container); + try { + await act(async () => { + root.render(); + }); + const buttons = [...container.querySelectorAll('button')]; + expect(buttons).toHaveLength(2); + expect(buttons.every(button => button.disabled)).toBe(true); + expect(formatCodeReviewReportMarkdownMock).not.toHaveBeenCalled(); + await act(async () => { + root.render(); + }); + expect(buttons.every(button => !button.disabled)).toBe(true); + await act(async () => { buttons[1].click(); }); + expect(writeFile).toHaveBeenCalledWith('/review.md', new TextEncoder().encode('# Review')); + } finally { + act(() => root.unmount()); + } + }); + it('uses the same copy icon as other copy buttons', () => { const html = renderToStaticMarkup( , diff --git a/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.tsx b/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.tsx index 7f44d8b743..3fb30c2be5 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.tsx @@ -14,7 +14,7 @@ import { import type { ReviewTeamRunManifest } from '@/shared/services/reviewTeamService'; interface CodeReviewReportExportActionsProps { - reviewData: CodeReviewReportData; + reviewData: CodeReviewReportData | null | undefined; runManifest?: ReviewTeamRunManifest; actions?: CodeReviewReportExportAction[]; variant?: 'icon' | 'footer'; @@ -105,11 +105,11 @@ export const CodeReviewReportExportActions: React.FC formatCodeReviewReportMarkdown( + () => reviewData ? formatCodeReviewReportMarkdown( reviewData, markdownLabels, { runManifest: reviewData.review_mode === 'deep' ? runManifest : undefined }, - ), + ) : '', [markdownLabels, reviewData, runManifest], ); @@ -190,6 +190,7 @@ export const CodeReviewReportExportActions: React.FC} onClick={handleOpenInEditor} + disabled={!reviewData} > {t('toolCards.codeReview.export.openMarkdown')} @@ -206,6 +207,7 @@ export const CodeReviewReportExportActions: React.FC : } /> @@ -217,6 +219,7 @@ export const CodeReviewReportExportActions: React.FC} /> @@ -228,7 +231,7 @@ export const CodeReviewReportExportActions: React.FC : } /> From 44b3d6fab876e31ce0af2313d979dcb5926df26b Mon Sep 17 00:00:00 2001 From: guantw Date: Mon, 21 Sep 2026 15:54:31 +0800 Subject: [PATCH 3/6] fix(review): inherit parent approval mode before submission Refresh review session permission settings from the parent before each submission and after backend session recreation. Clear inherited overrides when the parent returns to its default and retain surface ownership checks. --- .../inheritReviewPermissionMode.test.ts | 89 +++++++++++++++++++ .../services/inheritReviewPermissionMode.ts | 37 ++++++++ .../local/LocalSessionDriver.test.ts | 49 +++++++++- .../local/LocalSessionDriver.ts | 11 +++ 4 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 src/web-ui/src/flow_chat/services/inheritReviewPermissionMode.test.ts create mode 100644 src/web-ui/src/flow_chat/services/inheritReviewPermissionMode.ts diff --git a/src/web-ui/src/flow_chat/services/inheritReviewPermissionMode.test.ts b/src/web-ui/src/flow_chat/services/inheritReviewPermissionMode.test.ts new file mode 100644 index 0000000000..0bf772cef0 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/inheritReviewPermissionMode.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Session } from '../types/flow-chat'; +import { inheritReviewPermissionMode } from './inheritReviewPermissionMode'; + +const { getMode, updateMode } = vi.hoisted(() => ({ + getMode: vi.fn(), + updateMode: vi.fn(), +})); +vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ + agentAPI: { getSessionPermissionMode: getMode, updateSessionPermissionMode: updateMode }, +})); + +function session(overrides: Partial): Session { + return { + sessionId: 'child', + sessionKind: 'review', + parentSessionId: 'parent', + workspacePath: '/project', + config: {}, + ...overrides, + } as Session; +} + +describe('inheritReviewPermissionMode', () => { + beforeEach(() => { + vi.resetAllMocks(); + updateMode.mockResolvedValue({ mode: null }); + }); + + it.each(['review', 'deep_review'] as const)( + 'inherits auto approval for a %s session through the runtime API', + async (sessionKind) => { + getMode.mockResolvedValue({ mode: 'auto_approve' }); + const child = session({ sessionKind, remoteConnectionId: 'ssh-1', remoteSshHost: 'host-1' }); + const parent = session({ sessionId: 'parent', workspacePath: '/project/main', + remoteConnectionId: 'ssh-1', remoteSshHost: 'host-1' }); + await inheritReviewPermissionMode(child, new Map([['parent', parent]]), vi.fn()); + expect(getMode).toHaveBeenCalledWith({ + sessionId: 'parent', workspacePath: '/project/main', + remoteConnectionId: 'ssh-1', remoteSshHost: 'host-1', + }); + expect(updateMode).toHaveBeenCalledWith({ + sessionId: 'child', workspacePath: '/project', + remoteConnectionId: 'ssh-1', remoteSshHost: 'host-1', mode: 'auto_approve', + }); + }, + ); + + it('refreshes the selection and clears inherited access when the parent resets to default', async () => { + getMode.mockResolvedValueOnce({ mode: 'full_access' }) + .mockResolvedValueOnce({ mode: 'ask' }) + .mockResolvedValueOnce({ mode: null, turnMode: 'full_access' }); + for (const mode of ['full_access', 'ask', null]) { + await inheritReviewPermissionMode(session({}), new Map(), vi.fn()); + expect(updateMode).toHaveBeenLastCalledWith(expect.objectContaining({ mode })); + } + expect(getMode).toHaveBeenCalledTimes(3); + }); + + it('uses the persisted parent identity when only the review child is open', async () => { + getMode.mockResolvedValue({ mode: 'auto_approve' }); + await inheritReviewPermissionMode(session({ parentSessionId: undefined, + btwOrigin: { parentSessionId: 'restored-parent' }, + }), new Map(), vi.fn()); + expect(getMode).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'restored-parent' })); + expect(updateMode).toHaveBeenCalledWith(expect.objectContaining({ mode: 'auto_approve' })); + }); + + it.each(['normal', 'btw', 'subagent', 'miniapp'] as const)('does not change %s permissions', async (sessionKind) => { + await inheritReviewPermissionMode(session({ sessionKind }), new Map(), vi.fn()); + expect(getMode).not.toHaveBeenCalled(); + expect(updateMode).not.toHaveBeenCalled(); + }); + + it('does not retain stale inherited access after a failed parent read', async () => { + getMode.mockRejectedValue(new Error('parent offline')); + await expect(inheritReviewPermissionMode(session({}), new Map(), vi.fn())).rejects.toThrow('parent offline'); + expect(updateMode).not.toHaveBeenCalled(); + }); + + it('does not write to another surface if the peer changes during the read', async () => { + getMode.mockResolvedValue({ mode: 'full_access' }); + const assertCurrent = vi.fn().mockImplementationOnce(() => {}).mockImplementation(() => { + throw new Error('surface changed'); + }); + await expect(inheritReviewPermissionMode(session({}), new Map(), assertCurrent)).rejects.toThrow('surface changed'); + expect(updateMode).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/inheritReviewPermissionMode.ts b/src/web-ui/src/flow_chat/services/inheritReviewPermissionMode.ts new file mode 100644 index 0000000000..e7d1e90958 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/inheritReviewPermissionMode.ts @@ -0,0 +1,37 @@ +import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; +import type { Session } from '../types/flow-chat'; +import { resolveSessionRelationship } from '../utils/sessionMetadata'; +import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; + +/** Refresh the review session's permission selection before submitting work. */ +export async function inheritReviewPermissionMode( + child: Session, + sessions: ReadonlyMap, + assertCurrent: () => void, +): Promise { + const relationship = resolveSessionRelationship(child); + if (!relationship.isReview || !relationship.parentSessionId) { + return; + } + + const parent = sessions.get(relationship.parentSessionId); + const parentScope = { + sessionId: relationship.parentSessionId, + workspacePath: parent?.workspacePath ?? sessionProjectWorkspacePath(child), + remoteConnectionId: parent?.remoteConnectionId ?? child.remoteConnectionId, + remoteSshHost: parent?.remoteSshHost ?? child.remoteSshHost, + }; + assertCurrent(); + const { mode } = await agentAPI.getSessionPermissionMode(parentScope); + assertCurrent(); + // A cleared parent override must also clear a previously inherited override. + // Turn-only grants remain scoped to the parent's exact turn. + await agentAPI.updateSessionPermissionMode({ + sessionId: child.sessionId, + workspacePath: child.workspacePath, + remoteConnectionId: child.remoteConnectionId, + remoteSshHost: child.remoteSshHost, + mode: mode ?? null, + }); + assertCurrent(); +} diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts index 9993c54bf3..00f4f67dbb 100644 --- a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts @@ -5,11 +5,13 @@ import type { DialogTurn } from '../../types/flow-chat'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import { consumeSubmittedMessageArrival } from '../../services/submittedMessagePresentation'; -const { mockStartAcpDialogTurn, mockStartAgenticDialogTurn, mockTransition, mockUpdateSessionMetadata } = vi.hoisted(() => ({ +const { mockStartAcpDialogTurn, mockStartAgenticDialogTurn, mockTransition, mockUpdateSessionMetadata, mockGetMode, mockUpdateMode } = vi.hoisted(() => ({ mockStartAcpDialogTurn: vi.fn(), mockStartAgenticDialogTurn: vi.fn(), mockTransition: vi.fn(), mockUpdateSessionMetadata: vi.fn(), + mockGetMode: vi.fn(), + mockUpdateMode: vi.fn(), })); vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ @@ -17,7 +19,11 @@ vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ })); vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ - agentAPI: { startDialogTurn: mockStartAgenticDialogTurn }, + agentAPI: { + startDialogTurn: mockStartAgenticDialogTurn, + getSessionPermissionMode: mockGetMode, + updateSessionPermissionMode: mockUpdateMode, + }, })); vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ sessionAPI: {} })); @@ -166,3 +172,42 @@ describe('localSessionDriver.startTurn on an ACP session', () => { expect(mockUpdateSessionMetadata).not.toHaveBeenCalled(); }); }); + +describe('localSessionDriver review repair permissions', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockTransition.mockResolvedValue(true); + mockStartAgenticDialogTurn.mockResolvedValue(undefined); + mockGetMode.mockResolvedValue({ mode: 'auto_approve' }); + }); + + it('waits for parent permission inheritance before starting the repair', async () => { + const { context, session } = createHarness([]); + Object.assign(session, { sessionKind: 'review', parentSessionId: 'parent', mode: 'agentic' }); + let finishUpdate!: () => void; + mockUpdateMode.mockImplementation(() => new Promise((resolve) => { finishUpdate = resolve; })); + const submission = localSessionDriver.startTurn(context, { + ...startTurnInput(session), acpClientId: undefined, currentAgentType: 'ReviewFixer', + }, { createdLocalTurnId: null, hostAcceptedTurn: false }); + await vi.waitFor(() => expect(mockUpdateMode).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: SESSION_ID, mode: 'auto_approve' }), + )); + expect(mockStartAgenticDialogTurn).not.toHaveBeenCalled(); + finishUpdate(); + await submission; + expect(mockStartAgenticDialogTurn).toHaveBeenCalledTimes(1); + expect(mockStartAgenticDialogTurn).toHaveBeenCalledWith(expect.objectContaining({ + agentType: 'ReviewFixer', + })); + }); + + it('does not start repair with stale permissions if inheritance fails', async () => { + const { context, session } = createHarness([]); + Object.assign(session, { sessionKind: 'deep_review', parentSessionId: 'parent', mode: 'agentic' }); + mockUpdateMode.mockRejectedValue(new Error('permission update failed')); + await expect(localSessionDriver.startTurn(context, { + ...startTurnInput(session), acpClientId: undefined, currentAgentType: 'ReviewFixer', + }, { createdLocalTurnId: null, hostAcceptedTurn: false })).rejects.toThrow('permission update failed'); + expect(mockStartAgenticDialogTurn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts index 64fd224a6d..6172f94968 100644 --- a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts @@ -43,6 +43,7 @@ import { cleanupSaveState, updateSessionMetadata } from '../../services/flow-cha import { cleanupSessionBuffers } from '../../services/flow-chat-manager/TextChunkModule'; import { addSubmittedDialogTurn, applyGeneratingTitlePlaceholder } from '../shared'; import { initializeSessionTitleMetadata } from '../../services/sessionTitleMetadata'; +import { inheritReviewPermissionMode } from '../../services/inheritReviewPermissionMode'; const log = createLogger('LocalSessionDriver'); @@ -458,6 +459,11 @@ export const localSessionDriver: SessionDriver = { surfaceScope.assertCurrent('start ACP dialog turn'); context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); } else { + await inheritReviewPermissionMode( + updatedSession, + context.flowChatStore.getState().sessions, + () => surfaceScope.assertCurrent('inherit review session permission mode'), + ); try { tracker.hostSubmitStarted = true; await agentAPI.startDialogTurn({ @@ -493,6 +499,11 @@ export const localSessionDriver: SessionDriver = { surfaceScope.assertCurrent('load backend session retry'); await retryCreateBackendSession(context, sessionId); surfaceScope.assertCurrent('retry backend session creation'); + await inheritReviewPermissionMode( + updatedSession, + context.flowChatStore.getState().sessions, + () => surfaceScope.assertCurrent('inherit recreated review session permission mode'), + ); tracker.hostSubmitStarted = true; await agentAPI.startDialogTurn({ From bcc3e9bae896788fb8da489549741b4447fb90d6 Mon Sep 17 00:00:00 2001 From: guantw Date: Mon, 21 Sep 2026 15:54:48 +0800 Subject: [PATCH 4/6] fix(flowchat): keep search within narrow session panels Allow the search action group to shrink with the session panel and replace the narrow-window minimum width with a preferred width. Preserve the existing scene chrome and search result layout. --- .../src/flow_chat/components/modern/FlowChatHeader.scss | 7 ++++++- .../src/flow_chat/components/modern/FlowChatHeader.tsx | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss index 608601a253..d1501fddee 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss @@ -527,6 +527,11 @@ gap: var(--openbitfun-space-1); flex-shrink: 0; + &--searching { + flex-shrink: 1; + min-width: 0; + } + &--left { margin-right: var(--openbitfun-space-2); overflow: visible; @@ -535,7 +540,7 @@ @media (max-width: 900px) { &__search { - min-width: min(220px, 52vw); + width: min(220px, 52vw); max-width: min(300px, 56vw); } } diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index c0003f2ad2..5274e82b0f 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -593,7 +593,7 @@ export const FlowChatHeader: React.FC = ({ ); const rightActions = (
From 907ad443ac9d45056a6219deebe2b9ffaf682800 Mon Sep 17 00:00:00 2001 From: guantw Date: Mon, 21 Sep 2026 15:56:11 +0800 Subject: [PATCH 5/6] fix(review): localize action failures and retain diagnostics Show localized summaries for review action failures while retaining complete diagnostic text and structured launch error reasons. Preserve raw failure metadata in notifications and keep error details separate from the compact status header. --- .../btw/DeepReviewActionBar.test.tsx | 60 ++++++++++++++----- .../action-bar/DeepReviewActionBar.tsx | 45 +++++++++----- .../action-bar/ReviewActionHeader.test.tsx | 28 +++++++++ .../action-bar/ReviewActionHeader.tsx | 16 ++++- 4 files changed, 118 insertions(+), 31 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.test.tsx b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.test.tsx index 41c4878241..310f3148ed 100644 --- a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.test.tsx +++ b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.test.tsx @@ -32,6 +32,16 @@ const persistReviewActionStateMock = vi.hoisted(() => vi.fn()); const openBtwSessionInAuxPaneMock = vi.hoisted(() => vi.fn()); const notificationWarningMock = vi.hoisted(() => vi.fn()); +vi.mock('@/infrastructure/i18n', async (importOriginal) => { + const { default: errors } = await import('@/locales/zh-CN/errors.json'); + return { + ...await importOriginal(), + useI18n: () => ({ + t: (key: string) => key.replace(/^errors:/, '').split('.').reduce((value, part) => value?.[part], errors) ?? key, + }), + }; +}); + vi.mock('react-i18next', async () => { const { createTestI18nT } = await import('@/test/i18nTestUtils'); return { @@ -183,16 +193,6 @@ vi.mock('../../services/DeepReviewContinuationService', () => ({ continueDeepReviewSession: continueDeepReviewSessionMock, })); -vi.mock('@/shared/ai-errors/aiErrorPresenter', () => ({ - getAiErrorPresentation: () => ({ - category: 'network', - titleKey: 'test', - messageKey: 'test', - diagnostics: 'test diagnostics', - actions: [], - }), -})); - let JSDOMCtor: (new ( html?: string, options?: { pretendToBeVisual?: boolean; url?: string } @@ -299,7 +299,7 @@ describeWithJsdom('DeepReviewActionBar', () => { expect(container.querySelector('[role="status"]')).toBeTruthy(); }); - it('localizes the stable dialog-start prefix without translating provider details', async () => { + it('localizes the dialog-start summary while preserving the complete original diagnostic', async () => { const store = useReviewActionBarStore.getState(); store.showActionBar({ childSessionId: 'child-session', @@ -320,10 +320,13 @@ describeWithJsdom('DeepReviewActionBar', () => { root.render(); }); - expect(container.textContent).toContain( + const displayedError = container.querySelector('.deep-review-action-bar__error-message'); + expect(displayedError?.firstElementChild?.textContent).toBe( 'Unable to start this action: provider quota exhausted', ); - expect(container.textContent).not.toContain('Failed to start dialog turn:'); + expect(displayedError?.lastElementChild?.textContent).toBe( + 'Failed to start dialog turn: provider quota exhausted', + ); }); it.each([ @@ -351,7 +354,7 @@ describeWithJsdom('DeepReviewActionBar', () => { await act(async () => { startFixButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); - expect(notificationService.error).toHaveBeenCalledWith(message, { duration: 5000 }); + expect(notificationService.error).toHaveBeenCalledWith(message, { duration: 5000, metadata: { rawError: error.message } }); expect(container.textContent).toContain(message); expect(container.textContent).not.toContain('Failed to start dialog turn:'); }); @@ -414,6 +417,35 @@ describeWithJsdom('DeepReviewActionBar', () => { expect(itemCheckbox?.disabled).toBe(true); }); + it.each([new Error('network timeout: upstream did not respond'), 'network timeout: upstream did not respond'])( + 'localizes remediation failures and retains complete diagnostics: %s', async (failure) => { + const { notificationService } = await import('@/shared/notification-system'); + sendMessageMock.mockRejectedValueOnce(failure); + useReviewActionBarStore.getState().showActionBar({ + childSessionId: 'review-session', + parentSessionId: 'parent-session', + reviewMode: 'standard', + reviewData: { + summary: { recommended_action: 'request_changes' }, + remediation_plan: ['Fix the finding.'], + }, + phase: 'review_completed', + }); + await act(async () => root.render()); + const button = Array.from(container.querySelectorAll('button')) + .find(item => item.textContent?.includes('Start fixing'))!; + await act(async () => button.click()); + const [message, options] = vi.mocked(notificationService.error).mock.calls.at(-1)!; + expect(message).toMatch(/[\u3400-\u9fff]/); + expect(message).not.toContain('upstream did not respond'); + expect(options?.metadata?.rawError).toBe('network timeout: upstream did not respond'); + const displayedError = container.querySelector('.deep-review-action-bar__error-message'); + expect(displayedError?.textContent).toContain(message); + expect(displayedError?.lastElementChild?.textContent).toBe(options?.metadata?.rawError); + expect(useReviewActionBarStore.getState().phase).toBe('fix_timeout'); + }, + ); + it('uses a separate ReviewFixer agent for standard review remediation', async () => { useReviewActionBarStore.getState().showActionBar({ childSessionId: 'review-session', diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/DeepReviewActionBar.tsx b/src/web-ui/src/flow_chat/deep-review/action-bar/DeepReviewActionBar.tsx index 8fba66cfb0..bc9fbcf702 100644 --- a/src/web-ui/src/flow_chat/deep-review/action-bar/DeepReviewActionBar.tsx +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/DeepReviewActionBar.tsx @@ -1,6 +1,7 @@ import { Icon, Textarea, type IconName } from '@openbitfun/ui'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useI18n } from '@/infrastructure/i18n'; import { AlertTriangle, AlertCircle, @@ -188,6 +189,15 @@ const PHASE_CONFIG: Record = ({ childSessionId: scopedChildSessionId }) => { const { t } = useTranslation('flow-chat'); + const { t: translateError } = useI18n('errors'); + const localizeActionError = useCallback((error: unknown, fallback: string) => { + const rawMessage = normalizeActionErrorMessage(error); + const launchMessage = getReviewActionErrorMessage(error, t, fallback); + if (launchMessage !== rawMessage.trim()) return launchMessage; + const presentation = getAiErrorPresentation({ rawMessage }); + const title = presentation.category === 'unknown' ? fallback : translateError(presentation.titleKey); + return `${title} ${translateError(presentation.messageKey)}`; + }, [t, translateError]); const store = useReviewActionBarStore(); const scopedState = scopedChildSessionId ? getReviewActionBarStateForSession(store, scopedChildSessionId) @@ -522,13 +532,13 @@ export const ReviewActionBar: React.FC = ({ childSessionId store.updatePhase(isTimeout ? 'fix_timeout' : 'fix_failed', message, childSessionId); store.restore(childSessionId ?? undefined); notificationService.error( - message, - { duration: 5000 }, + localizeActionError(error, t('deepReviewActionBar.fixFailed')), + { duration: 5000, metadata: { rawError: msg } }, ); } finally { store.setActiveAction(null, undefined, childSessionId); } - }, [reviewData, childSessionId, childSession, selectedRemediationIds, remediationItems, completedRemediationIds, customInstructions, reviewMode, isDeepReview, decisionSelections, store, t]); + }, [reviewData, childSessionId, childSession, selectedRemediationIds, remediationItems, completedRemediationIds, customInstructions, reviewMode, isDeepReview, decisionSelections, store, t, localizeActionError]); const handleReviewFixes = useCallback(async () => { if (!isTauriRuntime()) { @@ -659,8 +669,11 @@ export const ReviewActionBar: React.FC = ({ childSessionId reviewMode, error, }); - const message = getReviewActionErrorMessage(error, t, t('deepReviewActionBar.actionStartFailed')); - notificationService.error(message, { duration: 5000 }); + const message = normalizeActionErrorMessage(error); + notificationService.error(localizeActionError(error, t('deepReviewActionBar.reviewError')), { + duration: 5000, + metadata: { rawError: message }, + }); } finally { store.setActiveAction(null, undefined, childSessionId); } @@ -668,6 +681,7 @@ export const ReviewActionBar: React.FC = ({ childSessionId childSession, childSessionId, confirmDeepReviewLaunch, + localizeActionError, parentSessionId, remediationModifiedFilePaths, remediationScopeRequiresWorkspaceFallback, @@ -723,12 +737,15 @@ export const ReviewActionBar: React.FC = ({ childSessionId store.minimize(childSessionId); } catch (error) { log.error('Failed to start DeepReview retry coverage', { childSessionId, error }); - const message = getReviewActionErrorMessage(error, t, t('deepReviewActionBar.retryIncompleteFailed')); - notificationService.error(message, { duration: 5000 }); + const message = normalizeActionErrorMessage(error); + notificationService.error(localizeActionError(error, t('deepReviewActionBar.retryIncompleteFailed')), { + duration: 5000, + metadata: { rawError: message }, + }); } finally { store.setActiveAction(null, undefined, childSessionId); } - }, [childSessionId, retryableSlices, store, t]); + }, [childSessionId, retryableSlices, store, t, localizeActionError]); const handleFillBackInput = useCallback(async () => { if (!reviewData) return; @@ -799,7 +816,7 @@ export const ReviewActionBar: React.FC = ({ childSessionId const message = t('deepReviewActionBar.resumeFailedMessage'); store.updatePhase('resume_failed', message, childSessionId ?? undefined); store.restore(childSessionId ?? undefined); - notificationService.error(message, { duration: 5000 }); + notificationService.error(message, { duration: 5000, metadata: { rawError: normalizeActionErrorMessage(error) } }); } finally { store.setActiveAction(null, undefined, childSessionId ?? undefined); } @@ -829,11 +846,7 @@ export const ReviewActionBar: React.FC = ({ childSessionId } }, []); - const displayErrorMessage = useMemo(() => { - if (!errorMessage) return null; - return getReviewActionErrorMessage(errorMessage, t, t('deepReviewActionBar.actionStartFailed')); - }, [errorMessage, t]); const handleCopyDiagnostics = useCallback(async () => { const detail = interruption?.errorDetail; @@ -931,7 +944,11 @@ export const ReviewActionBar: React.FC = ({ childSessionId PhaseIcon={PhaseIcon} phaseIconClass={phaseConfig.iconClass} phaseTitle={phaseTitle} - errorMessage={displayErrorMessage} + errorMessage={errorMessage} + errorSummary={errorMessage + ? localizeActionError(errorMessage, phaseTitle) + : undefined} + errorDetailsLabel={t('deepReviewActionBar.diagnosticsTechnicalDetails')} minimizeLabel={t('deepReviewActionBar.minimize')} onMinimize={handleMinimize} /> diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.test.tsx b/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.test.tsx index c4bc65351a..875ffdfcf1 100644 --- a/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.test.tsx +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { JSDOM } from 'jsdom'; import { renderToStaticMarkup } from 'react-dom/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ReviewActionHeader } from './ReviewActionHeader'; @@ -34,6 +35,33 @@ describe('ReviewActionHeader', () => { expect(html).toContain('aria-label="Minimize"'); }); + it('keeps the complete multiline failure outside the compact header and export controls', () => { + const error = 'Failed to start dialog turn: Session execution settings changed during turn admission; retry submission\n' + + 'Details: ' + 'long-unbroken-diagnostic-'.repeat(30); + const html = renderToStaticMarkup( + } + phaseIconClass="phase-error" + phaseTitle="Fix failed" + errorMessage={error} + errorSummary="Localized error summary" + errorDetailsLabel="Technical details" + minimizeLabel="Minimize" + onMinimize={vi.fn()} + />, + ); + const dom = new JSDOM(html); + const document = dom.window.document; + const details = document.querySelector('.deep-review-action-bar__error-message')!; + expect(details.lastElementChild?.textContent).toBe(error); + expect(details.textContent).toContain('Localized error summary'); + expect(details.textContent).toContain('Technical details'); + expect(details.closest('.deep-review-action-bar__status')).toBeNull(); + expect(details.closest('.deep-review-action-bar__controls')).toBeNull(); + expect(details.getAttribute('role')).toBe('status'); + dom.window.close(); + }); + it.each([null, { summary: { recommended_action: 'request_changes' } }])('keeps compact export actions while running with report data %j', (reviewData) => { const Icon = () => phase icon; renderToStaticMarkup( diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.tsx b/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.tsx index 058bc13161..ca777b6c1d 100644 --- a/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.tsx +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.tsx @@ -15,6 +15,8 @@ interface ReviewActionHeaderProps { phaseIconClass: string; phaseTitle: string; errorMessage?: string | null; + errorSummary?: string; + errorDetailsLabel?: string; minimizeLabel: string; onMinimize: () => void; } @@ -26,6 +28,8 @@ export const ReviewActionHeader: React.FC = ({ phaseIconClass, phaseTitle, errorMessage, + errorSummary, + errorDetailsLabel, minimizeLabel, onMinimize, }) => ( @@ -54,9 +58,15 @@ export const ReviewActionHeader: React.FC = ({ className={`deep-review-action-bar__icon ${phaseIconClass}`} /> {phaseTitle} - {errorMessage && ( - {errorMessage} - )}
+ {errorMessage && ( +
+ {errorSummary && errorSummary !== errorMessage && ( +
{errorSummary}
+ )} + {errorDetailsLabel &&
{errorDetailsLabel}
} +
{errorMessage}
+
+ )} ); From ec89f7ebd22a773abc76c6c0c5998851551c821f Mon Sep 17 00:00:00 2001 From: guantw Date: Mon, 21 Sep 2026 16:02:06 +0800 Subject: [PATCH 6/6] fix(review): localize platform failures and distinguish missing Git Classify a missing Git executable separately from invalid workspace paths. Expose stable failure codes and translate review panel errors while retaining the existing repository trust recovery flow. --- .../desktop/src/api/review_platform_api.rs | 83 +++++++------ .../src/review_platform.rs | 42 ++++++- .../review-platform/ReviewPlatformPanel.tsx | 110 +++++++++--------- .../review-platform/reviewErrors.test.ts | 109 +++++++++++++++++ .../panels/review-platform/reviewErrors.ts | 81 +++++++++++++ .../api/errors/TauriCommandError.ts | 10 ++ src/web-ui/src/locales/en-US/common.json | 55 +++++++++ src/web-ui/src/locales/zh-CN/common.json | 55 +++++++++ src/web-ui/src/locales/zh-TW/common.json | 55 +++++++++ 9 files changed, 498 insertions(+), 102 deletions(-) create mode 100644 src/web-ui/src/app/components/panels/review-platform/reviewErrors.test.ts create mode 100644 src/web-ui/src/app/components/panels/review-platform/reviewErrors.ts diff --git a/src/apps/desktop/src/api/review_platform_api.rs b/src/apps/desktop/src/api/review_platform_api.rs index 13f0451b4e..05f3b6c168 100644 --- a/src/apps/desktop/src/api/review_platform_api.rs +++ b/src/apps/desktop/src/api/review_platform_api.rs @@ -117,7 +117,7 @@ pub async fn review_platform_get_workspace_snapshot( "Failed to get review platform workspace snapshot: path={}, remote_id={:?}, error={}", request.repository_path, request.remote_id, error ); - review_platform_command_error("Failed to get review platform workspace snapshot", &error) + review_platform_ui_error(&error) }) } @@ -136,7 +136,7 @@ pub async fn review_platform_get_workspace_context( "Failed to get review platform workspace context: path={}, remote_id={:?}, error={}", request.repository_path, request.remote_id, error ); - review_platform_command_error("Failed to get review platform workspace context", &error) + review_platform_ui_error(&error) }) } @@ -159,10 +159,7 @@ pub async fn review_platform_get_pull_request_detail( request.pull_request_id, error ); - review_platform_command_error( - "Failed to get review platform pull request detail", - &error, - ) + review_platform_ui_error(&error) }) } @@ -185,7 +182,7 @@ pub async fn review_platform_get_pull_request_review_target( request.pull_request_id, error ); - review_platform_command_error("Failed to prepare pull request Review target", &error) + review_platform_ui_error(&error) }) } @@ -215,7 +212,7 @@ pub async fn review_platform_get_issue( request.issue_id, safe_error ); - safe_review_platform_command_error("Failed to get provider Issue evidence", &error) + review_platform_ui_error(&error) }) } @@ -243,24 +240,36 @@ pub async fn review_platform_get_pull_request_review_target_by_identity( request.pull_request_id, safe_error ); - safe_review_platform_command_error("Failed to prepare pull request Review target", &error) + review_platform_ui_error(&error) }) } -fn review_platform_command_error(context: &str, error: &ReviewPlatformError) -> String { - if let Some(repository_path) = error.untrusted_repository_path() { - return untrusted_repository_error_message(repository_path); - } - - format!("{context}: {error}") -} - -fn safe_review_platform_command_error(context: &str, error: &ReviewPlatformError) -> String { - if let Some(repository_path) = error.untrusted_repository_path() { - return untrusted_repository_error_message(repository_path); - } - - format!("{context}: {}", safe_review_platform_error(error)) +fn review_platform_ui_error(error: &ReviewPlatformError) -> String { + let code = match error { + ReviewPlatformError::RepositoryUntrusted { + repository_path, .. + } => { + return untrusted_repository_error_message(repository_path); + } + ReviewPlatformError::GitUnavailable => return error.to_string(), + ReviewPlatformError::InvalidRepository(_) => "invalidRepository", + ReviewPlatformError::RemoteNotFound(_) => "remoteNotFound", + ReviewPlatformError::UnsupportedPlatform(_) => "unsupportedPlatform", + ReviewPlatformError::Api(_) => "providerFailed", + ReviewPlatformError::Http { status: 401, .. } => "authenticationRequired", + ReviewPlatformError::Http { status: 403, .. } => "permissionDenied", + ReviewPlatformError::Http { status: 404, .. } => "notFound", + ReviewPlatformError::Http { .. } => "providerFailed", + ReviewPlatformError::Network(_) => "networkFailed", + ReviewPlatformError::Parse(_) => "invalidResponse", + ReviewPlatformError::StaleTarget(_) => "staleTarget", + ReviewPlatformError::EvidenceTooLarge { .. } => "evidenceTooLarge", + ReviewPlatformError::TargetIsPullRequest { .. } => "targetIsPullRequest", + }; + format!( + "review_platform_error:{code}: {}", + safe_review_platform_error(error) + ) } fn safe_review_platform_error(error: &ReviewPlatformError) -> String { @@ -277,6 +286,7 @@ fn safe_review_platform_error(error: &ReviewPlatformError) -> String { ReviewPlatformError::TargetIsPullRequest { .. } => { "requested Issue is a pull request".to_string() } + ReviewPlatformError::GitUnavailable => "Git is unavailable".to_string(), ReviewPlatformError::InvalidRepository(_) => "invalid repository".to_string(), ReviewPlatformError::RepositoryUntrusted { .. } => { "repository ownership is not trusted".to_string() @@ -312,10 +322,7 @@ pub async fn review_platform_get_pull_request_detail_page( request.per_page, error ); - review_platform_command_error( - "Failed to get review platform pull request detail page", - &error, - ) + review_platform_ui_error(&error) }) } @@ -341,7 +348,7 @@ pub async fn review_platform_get_pull_request_ci_log( request.ci_item_id, error ); - review_platform_command_error("Failed to get review platform CI log", &error) + review_platform_ui_error(&error) }) } @@ -357,7 +364,7 @@ pub async fn review_platform_update_auth_token( "Failed to update review platform auth token: platform={:?}, host={}, error={}", request.platform, request.host, error ); - format!("Failed to update review platform auth token: {}", error) + review_platform_ui_error(&error) }) } @@ -373,7 +380,7 @@ pub async fn review_platform_clear_auth_token( "Failed to clear review platform auth token: platform={:?}, host={}, error={}", request.platform, request.host, error ); - format!("Failed to clear review platform auth token: {}", error) + review_platform_ui_error(&error) }) } @@ -416,26 +423,18 @@ mod tests { }; assert_eq!( - review_platform_command_error("Failed to load review platform", &error), - "git_repository_untrusted: /srv/shared/repo" - ); - assert_eq!( - safe_review_platform_command_error("Failed to load review platform", &error), + review_platform_ui_error(&error), "git_repository_untrusted: /srv/shared/repo" ); } #[test] - fn review_platform_command_errors_keep_context_for_other_failures() { + fn review_platform_command_errors_use_stable_codes_for_other_failures() { let error = ReviewPlatformError::RemoteNotFound("origin".to_string()); assert_eq!( - review_platform_command_error("Failed to load review platform", &error), - "Failed to load review platform: Remote not found: origin" - ); - assert_eq!( - safe_review_platform_command_error("Failed to load review platform", &error), - "Failed to load review platform: provider remote was not found" + review_platform_ui_error(&error), + "review_platform_error:remoteNotFound: provider remote was not found" ); } diff --git a/src/crates/services/services-integrations/src/review_platform.rs b/src/crates/services/services-integrations/src/review_platform.rs index 424f04f562..5adb48d503 100644 --- a/src/crates/services/services-integrations/src/review_platform.rs +++ b/src/crates/services/services-integrations/src/review_platform.rs @@ -67,6 +67,8 @@ static TOKEN_STORE_TEMP_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic: #[derive(Debug, thiserror::Error)] pub enum ReviewPlatformError { + #[error("git_unavailable: Git is unavailable. Install Git and ensure it is on PATH in the environment running this workspace, then retry.")] + GitUnavailable, #[error("Invalid repository path: {0}")] InvalidRepository(String), #[error("Repository ownership is not trusted: {repository_path}")] @@ -4655,12 +4657,7 @@ async fn execute_git_command( .args(args) .output() .await - .map_err(|error| { - ReviewPlatformError::InvalidRepository(format!( - "Failed to execute git command: {}", - error - )) - })?; + .map_err(|error| git_execution_error(current_dir_path, error))?; if output.status.success() { return Ok(String::from_utf8_lossy(&output.stdout).to_string()); @@ -4674,6 +4671,14 @@ async fn execute_git_command( Err(classify_git_command_failure(current_dir, message)) } +fn git_execution_error(current_dir: &Path, error: std::io::Error) -> ReviewPlatformError { + // Starting a process also returns NotFound when its working directory is missing. + if error.kind() == std::io::ErrorKind::NotFound && current_dir.is_dir() { + return ReviewPlatformError::GitUnavailable; + } + ReviewPlatformError::InvalidRepository(format!("Failed to execute git command: {}", error)) +} + fn review_evidence_error(error: ReviewPlatformError, resource: &str) -> ReviewPlatformError { match error { ReviewPlatformError::EvidenceTooLarge { limit, .. } => { @@ -7933,6 +7938,31 @@ mod tests { )) } + #[test] + fn git_execution_errors_distinguish_missing_git_from_workspace_and_permission_failures() { + let current_dir = std::env::temp_dir(); + let missing_dir = temp_token_store_path("missing-workspace"); + let error = git_execution_error( + ¤t_dir, + std::io::Error::from(std::io::ErrorKind::NotFound), + ); + assert!(matches!(error, ReviewPlatformError::GitUnavailable)); + assert!(error.to_string().starts_with("git_unavailable:")); + assert!(!error.to_string().contains("Invalid repository path")); + + let error = git_execution_error( + &missing_dir, + std::io::Error::from(std::io::ErrorKind::NotFound), + ); + assert!(matches!(error, ReviewPlatformError::InvalidRepository(_))); + + let error = git_execution_error( + ¤t_dir, + std::io::Error::from(std::io::ErrorKind::PermissionDenied), + ); + assert!(matches!(error, ReviewPlatformError::InvalidRepository(_))); + } + fn spawn_single_review_response(response: Vec) -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("mock provider should bind"); let address = listener.local_addr().expect("mock provider address"); diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx index fddd721104..8326ea41fb 100644 --- a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx @@ -63,7 +63,6 @@ import { parsePullRequestUrl, remoteMatchesPullRequestLink } from '@/shared/util import { useContextStore } from '@/shared/stores/contextStore'; import { quickActions } from '@/shared/services/ide-control'; import { - describeGitTrustFailure, withGitRepositoryTrustRecovery, } from '@/shared/services/gitTrustService'; import type { PullRequestContext } from '@/shared/types/context'; @@ -83,6 +82,7 @@ import { samePullRequestIdentity, type PullRequestReviewFreshness, } from './reviewLinking'; +import { reviewPlatformErrorMessage, reviewErrorText, reviewAuthErrorMessage, type ReviewErrorFallback } from './reviewErrors'; import './ReviewPlatformPanel.scss'; const log = createLogger('ReviewPlatformPanel'); @@ -169,11 +169,6 @@ const detailPageCache = new Map(); const reviewLaunchesInFlight = new Set(); const EMPTY_REVIEW_THREADS: ReviewPlatformThread[] = []; -function reviewPlatformErrorMessage(error: unknown, fallback: string): string { - return describeGitTrustFailure(error) - ?? (error instanceof Error ? error.message : fallback); -} - function detailPageInfo(pagination: ReviewPlatformPagination, itemCount: number): PageInfo { const pageIndex = Math.max(0, (pagination.page || 1) - 1); const perPage = Math.max(1, pagination.perPage || itemCount || 1); @@ -377,7 +372,7 @@ function authLabel(account: ReviewPlatformAccount | null): string { case 'expired': return 'Expired'; case 'error': - return 'Auth error'; + return i18nService.t('common:reviewPlatform.messages.authError'); default: return 'Not connected'; } @@ -399,14 +394,14 @@ function authSourceLabel(source: ReviewPlatformAccount['authSource'] | undefined } function authChallengeTitle(challenge: ReviewPlatformAuthChallenge): string { - if (challenge.platform === 'github') return 'GitHub CLI authentication required'; + if (challenge.platform === 'github') return i18nService.t('common:reviewPlatform.messages.ghAuthRequired'); switch (challenge.state) { case 'missing': - return 'Token required'; + return i18nService.t('common:reviewPlatform.messages.tokenRequiredTitle'); case 'insufficient_scope': - return 'Token permissions required'; + return i18nService.t('common:reviewPlatform.messages.tokenScopeTitle'); default: - return 'Token update required'; + return i18nService.t('common:reviewPlatform.messages.tokenUpdateTitle'); } } @@ -712,6 +707,7 @@ export const ReviewPlatformPanel: React.FC = ({ detailOnly = false, }) => { const { t } = useI18n('panels/git'); + const { t: tReview } = useI18n('flow-chat'); const authFormId = useId(); const backButtonRef = useRef(null); const selectedRowRef = useRef(null); @@ -733,8 +729,13 @@ export const ReviewPlatformPanel: React.FC = ({ const [activeTab, setActiveTab] = useState('overview'); const [loading, setLoading] = useState(true); const [detailLoading, setDetailLoading] = useState(false); - const [detailError, setDetailError] = useState(null); - const [error, setError] = useState(null); + const [detailFailure, setDetailFailure] = useState<{ cause: unknown; fallback: ReviewErrorFallback } | null>(null); + const detailError = detailFailure ? reviewPlatformErrorMessage(detailFailure.cause, t, detailFailure.fallback) : null; + const setDetailError = useCallback((cause: unknown, fallback: ReviewErrorFallback = 'detailsFailed') => { + setDetailFailure(cause === null ? null : { cause, fallback }); + }, []); + const [snapshotError, setSnapshotError] = useState<{ cause: unknown } | null>(null); + const error = snapshotError ? reviewPlatformErrorMessage(snapshotError.cause, t) : null; const [query, setQuery] = useState(''); const [stateFilter, setStateFilter] = useState('all'); const serverStateFilter = useRef('all'); @@ -750,12 +751,16 @@ export const ReviewPlatformPanel: React.FC = ({ const [expandedFileKeys, setExpandedFileKeys] = useState>(() => new Set()); const [expandedCiItemIds, setExpandedCiItemIds] = useState>(() => new Set()); const [ciLogById, setCiLogById] = useState>({}); - const [ciLogErrorById, setCiLogErrorById] = useState>({}); + const [ciLogErrorById, setCiLogErrorById] = useState>({}); const [ciLogLoadingIds, setCiLogLoadingIds] = useState>(() => new Set()); const [authModalOpen, setAuthModalOpen] = useState(false); const [authToken, setAuthToken] = useState(''); const [authSaving, setAuthSaving] = useState(false); - const [authError, setAuthError] = useState(null); + const [authFailure, setAuthFailure] = useState<{ cause: unknown; fallback: ReviewErrorFallback } | null>(null); + const authError = authFailure ? reviewPlatformErrorMessage(authFailure.cause, t, authFailure.fallback) : null; + const setAuthError = useCallback((cause: unknown, fallback: ReviewErrorFallback = 'saveTokenFailed') => { + setAuthFailure(cause === null ? null : { cause, fallback }); + }, []); const [reviewLaunching, setReviewLaunching] = useState(false); const { confirmDeepReviewLaunch, deepReviewConsentDialog } = useDeepReviewConsent(); @@ -841,7 +846,7 @@ export const ReviewPlatformPanel: React.FC = ({ setDetail(null); setVerifiedDetailKey(null); setDetailError(null); - setError('No active workspace is available.'); + setSnapshotError({ cause: 'No active workspace is available.' }); setLoading(false); return; } @@ -868,7 +873,7 @@ export const ReviewPlatformPanel: React.FC = ({ setDetail(null); setVerifiedDetailKey(null); setDetailError(null); - setError(null); + setSnapshotError(null); setLoading(false); return; } else { @@ -880,7 +885,7 @@ export const ReviewPlatformPanel: React.FC = ({ } setLoading(true); - setError(null); + setSnapshotError(null); try { const repository = { workspaceId, repositoryPath: workspacePath }; const fetchSnapshot = () => detailOnly @@ -912,15 +917,14 @@ export const ReviewPlatformPanel: React.FC = ({ } } catch (err) { if (snapshotRequestSeq.current !== requestSeq) return; - const message = reviewPlatformErrorMessage(err, 'Failed to load pull requests'); - setError(message); + setSnapshotError({ cause: err }); log.error('Failed to load review platform snapshot', { workspacePath, error: err }); } finally { if (snapshotRequestSeq.current === requestSeq) { setLoading(false); } } - }, [detailOnly, workspacePath, workspaceId]); + }, [setDetailError, detailOnly, workspacePath, workspaceId]); const loadDetail = useCallback(async (repo: ReviewPlatformRepositoryRef | null, remoteId: string, pullRequestId: string, options?: { force?: boolean }) => { const requestSeq = ++detailRequestSeq.current; @@ -964,7 +968,7 @@ export const ReviewPlatformPanel: React.FC = ({ } catch (err) { if (detailRequestSeq.current !== requestSeq) return; log.error('Failed to load pull request detail', { pullRequestId, error: err }); - setDetailError(reviewPlatformErrorMessage(err, 'Failed to load pull request details.')); + setDetailError(err); if (!cached) { setDetail(null); } @@ -973,7 +977,7 @@ export const ReviewPlatformPanel: React.FC = ({ setDetailLoading(false); } } - }, [workspacePath, workspaceId]); + }, [setDetailError, workspacePath, workspaceId]); const applySectionPagination = useCallback((section: Exclude, pagination: ReviewPlatformPagination) => { if (section === 'ci') { @@ -1043,13 +1047,13 @@ export const ReviewPlatformPanel: React.FC = ({ } catch (err) { if (detailSectionRequestSeq.current !== requestSeq) return; log.error('Failed to load pull request detail section', { pullRequestId, section, page, perPage, error: err }); - setDetailError(reviewPlatformErrorMessage(err, 'Failed to load pull request details.')); + setDetailError(err); } finally { if (detailSectionRequestSeq.current === requestSeq) { setDetailLoading(false); } } - }, [applySectionPagination, loadDetail, workspacePath, workspaceId]); + }, [setDetailError, applySectionPagination, loadDetail, workspacePath, workspaceId]); useEffect(() => { serverStateFilter.current = 'all'; @@ -1083,7 +1087,7 @@ export const ReviewPlatformPanel: React.FC = ({ return; } void loadDetail(repository, selectedRemoteId, selectedPrId); - }, [loadDetail, repository, selectedPrId, selectedRemoteId, workspacePath]); + }, [setDetailError, loadDetail, repository, selectedPrId, selectedRemoteId, workspacePath]); useEffect(() => { if (!snapshot.remotes.length) return; @@ -1122,6 +1126,7 @@ export const ReviewPlatformPanel: React.FC = ({ setSelectedPrId(targetPullRequestId); } }, [ + setDetailError, detailOnly, initialPullRequestId, initialPullRequestTarget, @@ -1348,7 +1353,7 @@ export const ReviewPlatformPanel: React.FC = ({ rememberRemote(workspaceId, remoteId || null); setSnapshot(emptySnapshot()); void loadSnapshot(remoteId || null, { page: 1, state: 'all' }); - }, [loadSnapshot, workspaceId]); + }, [setDetailError, loadSnapshot, workspaceId]); const handleStateChange = useCallback((state: ListStateFilter) => { setPanelView('list'); @@ -1368,7 +1373,7 @@ export const ReviewPlatformPanel: React.FC = ({ setDetailError(null); setPageIndex(nextPage - 1); void loadSnapshot(listRemoteId, { page: nextPage }); - }, [listRemoteId, loadSnapshot]); + }, [setDetailError, listRemoteId, loadSnapshot]); const toggleFileExpanded = useCallback((key: string) => { setExpandedFileKeys(prev => { @@ -1477,8 +1482,7 @@ export const ReviewPlatformPanel: React.FC = ({ setCiLogById(prev => ({ ...prev, [item.id]: nextLog })); return nextLog; } catch (err) { - const message = reviewPlatformErrorMessage(err, 'Failed to load CI error log.'); - setCiLogErrorById(prev => ({ ...prev, [item.id]: message })); + setCiLogErrorById(prev => ({ ...prev, [item.id]: { cause: err } })); log.error('Failed to load CI log', { itemId: item.id, error: err }); return null; } finally { @@ -1517,7 +1521,7 @@ export const ReviewPlatformPanel: React.FC = ({ metadata?: Record; }) => { if (!parentSession) { - notificationService.warning('Open or create a chat session before sending PR context.', { duration: 3500 }); + notificationService.warning(i18nService.t('common:reviewPlatform.messages.chatRequired'), { duration: 3500 }); return; } @@ -1560,7 +1564,7 @@ export const ReviewPlatformPanel: React.FC = ({ const handleStartReview = useCallback(async () => { if (!workspacePath || !selectedRemote || !repository || !selectedPr || !parentSession) { - notificationService.warning('Open or create a chat session before reviewing this pull request.', { + notificationService.warning(i18nService.t('common:reviewPlatform.messages.reviewChatRequired'), { duration: 3500, }); return; @@ -1652,7 +1656,7 @@ export const ReviewPlatformPanel: React.FC = ({ prepared, }); if (launched.launchStatus === 'uncertain') { - notificationService.warning('Review started, but its start acknowledgement is uncertain.', { + notificationService.warning(i18nService.t('common:reviewPlatform.messages.reviewUncertain'), { duration: 8000, }); } @@ -1662,7 +1666,7 @@ export const ReviewPlatformPanel: React.FC = ({ error: reviewError, }); notificationService.error( - reviewError instanceof Error ? reviewError.message : 'Failed to start pull request Review.', + reviewPlatformErrorMessage(reviewError, tReview, 'reviewFailed'), { duration: 6000 }, ); } finally { @@ -1674,6 +1678,7 @@ export const ReviewPlatformPanel: React.FC = ({ } }, [ confirmDeepReviewLaunch, + tReview, latestCurrentReview?.lifecycle, parentSession, repository, @@ -1748,7 +1753,7 @@ export const ReviewPlatformPanel: React.FC = ({ setAuthToken(''); setAuthError(null); setAuthModalOpen(true); - }, []); + }, [setAuthError]); const handleSaveAuthToken = useCallback(async () => { if (!selectedRemote || selectedRemote.platform === 'unknown' || selectedRemote.platform === 'github') return; @@ -1770,13 +1775,12 @@ export const ReviewPlatformPanel: React.FC = ({ setAuthToken(''); refreshAuthSnapshot(selectedRemote.id); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to save token.'; - setAuthError(message); + setAuthError(err, 'saveTokenFailed'); log.error('Failed to save review platform token', { error: err, host: selectedRemote.host }); } finally { setAuthSaving(false); } - }, [authToken, refreshAuthSnapshot, selectedRemote]); + }, [setAuthError, authToken, refreshAuthSnapshot, selectedRemote]); const handleOpenGithubAuthTerminal = useCallback(async () => { if (!selectedRemote || selectedRemote.platform !== 'github') return; @@ -1791,13 +1795,12 @@ export const ReviewPlatformPanel: React.FC = ({ duration: 3500, }); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to open GitHub CLI authentication.'; - setAuthError(message); + setAuthError(err, 'openAuthFailed'); log.error('Failed to prepare GitHub CLI authentication', { error: err, host: selectedRemote.host }); } finally { setAuthSaving(false); } - }, [selectedRemote, workspacePath]); + }, [setAuthError, selectedRemote, workspacePath]); const handleCopyGithubAuthCommand = useCallback(async () => { if (!selectedRemote || selectedRemote.platform !== 'github') return; @@ -1806,11 +1809,10 @@ export const ReviewPlatformPanel: React.FC = ({ await systemAPI.setClipboard(`gh auth login --hostname ${selectedRemote.host}`); notificationService.success('GitHub CLI login command copied.', { duration: 2500 }); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to copy GitHub CLI login command.'; - setAuthError(message); + setAuthError(err, 'copyAuthFailed'); log.error('Failed to copy GitHub CLI authentication command', { error: err, host: selectedRemote.host }); } - }, [selectedRemote]); + }, [setAuthError, selectedRemote]); const handleClearAuthToken = useCallback(async () => { if (!selectedRemote || selectedRemote.platform === 'unknown') return; @@ -1823,14 +1825,13 @@ export const ReviewPlatformPanel: React.FC = ({ }); refreshAuthSnapshot(selectedRemote.id); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to clear token.'; - setAuthError(message); + setAuthError(err, 'clearTokenFailed'); setAuthModalOpen(true); log.error('Failed to clear review platform token', { error: err, host: selectedRemote.host }); } finally { setAuthSaving(false); } - }, [refreshAuthSnapshot, selectedRemote]); + }, [setAuthError, refreshAuthSnapshot, selectedRemote]); const renderAuthGate = useCallback((mode: 'inline' | 'detail' = 'inline') => { if (!authChallenge || !selectedRemote || selectedRemote.platform === 'unknown') return null; @@ -1842,7 +1843,7 @@ export const ReviewPlatformPanel: React.FC = ({ tone="warning" role="status" title={authChallengeTitle(authChallenge)} - message={authChallenge.message} + message={reviewAuthErrorMessage(authChallenge, t)} description={ {authChallenge.host} · {authChallenge.projectPath}
@@ -1861,7 +1862,7 @@ export const ReviewPlatformPanel: React.FC = ({
} /> ); - }, [authChallenge, authSaving, handleOpenAuthModal, loading, refreshAuthSnapshot, selectedRemote]); + }, [authChallenge, authSaving, handleOpenAuthModal, loading, refreshAuthSnapshot, selectedRemote, t]); const handleRetryDetail = useCallback(() => { if ((!repository && !workspacePath) || !selectedRemoteId || !selectedPrId) return; @@ -1914,9 +1915,9 @@ export const ReviewPlatformPanel: React.FC = ({ ? resolvedPullRequestStatistics(selectedPrFromList, detail) : displayPr; const displayLineStats = resolvedLineStats(displayStatistics); - const emptyStateMessage = snapshot.message - || (account && account.authState !== 'connected' && account.authState !== 'not_required' ? account.message : null) - || (selectedRemote && selectedRemote.authState !== 'connected' && selectedRemote.authState !== 'not_required' ? selectedRemote.message : null) + const emptyStateMessage = reviewErrorText(snapshot.message, t) + || (account && account.authState !== 'connected' && account.authState !== 'not_required' ? reviewErrorText(account.message, t) : null) + || (selectedRemote && selectedRemote.authState !== 'connected' && selectedRemote.authState !== 'not_required' ? reviewErrorText(selectedRemote.message, t) : null) || (snapshot.remotes.length ? isGithubUserList && !query.trim() ? 'No open pull requests authored by the current GitHub CLI account.' @@ -1930,7 +1931,7 @@ export const ReviewPlatformPanel: React.FC = ({ ? `${displayPr.checks.pending} pending` : 'All checks passed'; const reviewStatusText = latestCurrentReview - ? currentPullRequestReviewStatusText(latestCurrentReview) + ? reviewErrorText(currentPullRequestReviewStatusText(latestCurrentReview), t) : latestStaleReview ? 'Previous Review is stale because the PR revisions or runtime evidence changed' : latestUnknownReview @@ -2300,7 +2301,8 @@ export const ReviewPlatformPanel: React.FC = ({ const isCiExpanded = expandedCiItemIds.has(item.id); const ciLog = ciLogById[item.id]; const ciLogLoading = ciLogLoadingIds.has(item.id); - const ciLogError = ciLogErrorById[item.id]; + const ciLogError = ciLogErrorById[item.id] + ? reviewPlatformErrorMessage(ciLogErrorById[item.id].cause, t, 'ciLogFailed') : null; const logAvailable = canLoadCiLog(selectedRemote, item); const expandable = canExpandCiItem(selectedRemote, item); return ( diff --git a/src/web-ui/src/app/components/panels/review-platform/reviewErrors.test.ts b/src/web-ui/src/app/components/panels/review-platform/reviewErrors.test.ts new file mode 100644 index 0000000000..9c1cff3e4c --- /dev/null +++ b/src/web-ui/src/app/components/panels/review-platform/reviewErrors.test.ts @@ -0,0 +1,109 @@ +import { createInstance } from 'i18next'; +import { describe, expect, it } from 'vitest'; +import { createTauriCommandError } from '@/infrastructure/api/errors/TauriCommandError'; +import en from '@/locales/en-US/common.json'; +import zh from '@/locales/zh-CN/common.json'; +import zhReview from '@/locales/zh-CN/flow-chat.json'; +import tw from '@/locales/zh-TW/common.json'; +import { reviewPlatformErrorMessage } from './reviewErrors'; +import { reviewErrorText, reviewAuthErrorMessage } from './reviewErrors'; + +describe('Pull Requests missing Git message', () => { + it('uses the selected locale and updates an existing error after language changes', async () => { + const i18n = createInstance(); + await i18n.init({ + lng: 'zh-CN', + fallbackLng: 'en-US', + resources: { + 'zh-CN': { common: zh }, + 'en-US': { common: en }, + 'zh-TW': { common: tw }, + }, + }); + const error = createTauriCommandError( + 'review_platform_get_workspace_snapshot', + 'git_unavailable: Git is unavailable.', + ); + const translate = (key: string) => i18n.t(key); + + expect(reviewPlatformErrorMessage(error, translate)).toBe( + 'Git 不可用。请在运行此工作区的环境中安装 Git 并将其加入 PATH,然后重试。', + ); + await i18n.changeLanguage('en-US'); + expect(reviewPlatformErrorMessage(error, translate)).toBe(en.reviewPlatform.errors.gitUnavailable); + await i18n.changeLanguage('zh-TW'); + expect(reviewPlatformErrorMessage(error, translate)).toBe(tw.reviewPlatform.errors.gitUnavailable); + }); + + it('retains the stable error through Peer and JSON-RPC wrappers used by workspace loading', () => { + const code = 'git_unavailable: Git is unavailable.'; + const translate = () => zh.reviewPlatform.errors.gitUnavailable; + for (const originalError of [ + { message: 'Host command failed', details: { originalError: code } }, + Object.assign(new Error('Invalid params'), { code: -32602, data: code }), + ]) { + const error = createTauriCommandError('review_platform_get_workspace_context', originalError); + expect(reviewPlatformErrorMessage(error, translate)).toBe(zh.reviewPlatform.errors.gitUnavailable); + } + }); + + it('keeps other failures distinct from missing Git and localizes the unknown-error fallback', () => { + const translate = () => zh.reviewPlatform.errors.loadFailed; + for (const message of [ + 'Invalid repository path: No such file or directory', + 'Failed to execute git command: Permission denied', + ]) { + expect(reviewPlatformErrorMessage(new Error(message), translate)).toBe(zh.reviewPlatform.errors.loadFailed); + } + expect(reviewPlatformErrorMessage({}, translate)).toBe(zh.reviewPlatform.errors.loadFailed); + }); +}); + +describe('Review-platform user-visible failures', () => { + it('preserves the specific launch failure and its diagnostic reason', async () => { + const i18n = createInstance(); + await i18n.init({ lng: 'zh-CN', resources: { 'zh-CN': { common: zh, 'flow-chat': zhReview } } }); + const reason = 'The pull request contains no reviewable changed files.'; + const error = Object.assign(new Error(reason), { + launchErrorMessageKey: 'deepReviewActionBar.launchError.target', + originalMessage: reason, + }); + expect(reviewPlatformErrorMessage(error, (key, options) => i18n.t(key, options), 'reviewFailed')) + .toBe(`${zhReview.deepReviewActionBar.launchError.target}\n${reason}`); + }); + + it('localizes each backend failure code through wrapped transports', async () => { + const i18n = createInstance(); + await i18n.init({ lng: 'zh-CN', resources: { 'zh-CN': { common: zh } } }); + const t = (key: string) => i18n.t(key); + for (const [code, translated] of Object.entries(zh.reviewPlatform.errors)) { + if (['gitUnavailable', 'repositoryUntrusted', 'loadFailed'].includes(code)) continue; + const error = createTauriCommandError('review_platform_get_pull_request_detail_page', { + message: 'Host command failed', + details: { originalError: 'review_platform_error:' + code + ': internal English diagnostic' }, + }); + expect(reviewPlatformErrorMessage(error, t)).toBe(translated); + } + expect(reviewPlatformErrorMessage(new Error('secret internal diagnostic'), t, 'saveTokenFailed')) + .toBe(zh.reviewPlatform.messages.saveTokenFailed); + }); + + it('keeps validation messages specific in English and translates only failure messages while preserving ordinary status text', async () => { + const i18n = createInstance(); + await i18n.init({ lng: 'en-US', resources: { 'en-US': { common: en }, 'zh-CN': { common: zh } } }); + const t = (key: string, options?: Record) => i18n.t(key, options); + expect(reviewPlatformErrorMessage('Token is required.', t, 'saveTokenFailed')).toBe('Token is required.'); + await i18n.changeLanguage('zh-CN'); + for (const key of ['headUnavailable', 'noActionsJob', 'reviewResultUnavailable'] as const) { + expect(reviewErrorText(en.reviewPlatform.messages[key], t)).toBe(zh.reviewPlatform.messages[key]); + } + const challenge = { platform: 'gitlab', host: 'git.example', state: 'insufficient_scope' } as const; + expect(reviewAuthErrorMessage({ ...challenge, remoteId: 'origin', projectPath: 'example/repo', message: 'English backend text', requiredScopes: [] }, t)) + .toBe(zh.reviewPlatform.auth.insufficientScope.replace('{{host}}', 'git.example')); + expect(reviewErrorText('Provider-authored job description', t)).toBe('Provider-authored job description'); + expect(reviewErrorText('Review complete · 2 findings · high · limited coverage', t)) + .toBe('Review complete · 2 findings · high · limited coverage'); + expect(reviewErrorText('Review complete · 0 findings · limited coverage', t)) + .toBe('Review complete · 0 findings · limited coverage'); + }); +}); diff --git a/src/web-ui/src/app/components/panels/review-platform/reviewErrors.ts b/src/web-ui/src/app/components/panels/review-platform/reviewErrors.ts new file mode 100644 index 0000000000..57ac263bab --- /dev/null +++ b/src/web-ui/src/app/components/panels/review-platform/reviewErrors.ts @@ -0,0 +1,81 @@ +import { getReviewActionErrorMessage } from '@/flow_chat/deep-review/action-bar/actionBarFormatting'; +import type { DeepReviewLaunchError } from '@/flow_chat/deep-review/launch/launchErrors'; +import { describeGitTrustFailure } from '@/shared/services/gitTrustService'; +import { isGitUnavailableError, isGitRepositoryUntrustedError, reviewPlatformErrorCode } from '@/infrastructure/api/errors/TauriCommandError'; +import type { ReviewPlatformAuthChallenge } from '@/infrastructure/api'; + +type ReviewTranslate = (key: string, options?: Record) => string; + +export type ReviewErrorFallback = 'loadFailed' | 'detailsFailed' | 'ciLogFailed' | 'reviewFailed' | 'saveTokenFailed' | 'clearTokenFailed' | 'openAuthFailed' | 'copyAuthFailed'; + +export function reviewPlatformErrorMessage(error: unknown, t: ReviewTranslate, fallback: ReviewErrorFallback = 'loadFailed'): string { + if ((error as DeepReviewLaunchError | null)?.launchErrorMessageKey) { + return getReviewActionErrorMessage(error, (key, options) => t(key, { ...options, ns: 'flow-chat' }), t('common:reviewPlatform.messages.reviewFailed')); + } + if (isGitUnavailableError(error)) return t('common:reviewPlatform.errors.gitUnavailable'); + if (isGitRepositoryUntrustedError(error)) return describeGitTrustFailure(error) ?? t('common:reviewPlatform.errors.repositoryUntrusted'); + switch (reviewPlatformErrorCode(error)) { + case 'invalidRepository': return t('common:reviewPlatform.errors.invalidRepository'); + case 'repositoryUntrusted': return t('common:reviewPlatform.errors.repositoryUntrusted'); + case 'remoteNotFound': return t('common:reviewPlatform.errors.remoteNotFound'); + case 'unsupportedPlatform': return t('common:reviewPlatform.errors.unsupportedPlatform'); + case 'providerFailed': return t('common:reviewPlatform.errors.providerFailed'); + case 'authenticationRequired': return t('common:reviewPlatform.errors.authenticationRequired'); + case 'permissionDenied': return t('common:reviewPlatform.errors.permissionDenied'); + case 'notFound': return t('common:reviewPlatform.errors.notFound'); + case 'networkFailed': return t('common:reviewPlatform.errors.networkFailed'); + case 'invalidResponse': return t('common:reviewPlatform.errors.invalidResponse'); + case 'staleTarget': return t('common:reviewPlatform.errors.staleTarget'); + case 'evidenceTooLarge': return t('common:reviewPlatform.errors.evidenceTooLarge'); + case 'targetIsPullRequest': return t('common:reviewPlatform.errors.targetIsPullRequest'); + } + const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; + const localized = localizedErrorText(message, t); + if (localized !== undefined) return localized; + // Unknown technical failures stay in the diagnostic log, not in the UI. + switch (fallback) { + case 'loadFailed': return t('common:reviewPlatform.errors.loadFailed'); + case 'detailsFailed': return t('common:reviewPlatform.messages.detailsFailed'); + case 'ciLogFailed': return t('common:reviewPlatform.messages.ciLogFailed'); + case 'reviewFailed': return t('common:reviewPlatform.messages.reviewFailed'); + case 'saveTokenFailed': return t('common:reviewPlatform.messages.saveTokenFailed'); + case 'clearTokenFailed': return t('common:reviewPlatform.messages.clearTokenFailed'); + case 'openAuthFailed': return t('common:reviewPlatform.messages.openAuthFailed'); + case 'copyAuthFailed': return t('common:reviewPlatform.messages.copyAuthFailed'); + } +} + +function localizedErrorText(message: string, t: ReviewTranslate): string | undefined { + switch (message) { + case "Review failed \u00b7 open to inspect": return t('common:reviewPlatform.messages.reviewFailedInspect'); + case "Review complete \u00b7 result unavailable \u00b7 open to inspect": return t('common:reviewPlatform.messages.reviewResultUnavailable'); + case "Review error": return t('common:reviewPlatform.messages.reviewError'); + case "No active workspace is available.": return t('common:reviewPlatform.messages.noWorkspace'); + case "This link is not a supported pull request URL.": return t('common:reviewPlatform.messages.unsupportedLink'); + case "Open or create a chat session before sending PR context.": return t('common:reviewPlatform.messages.chatRequired'); + case "Open or create a chat session before reviewing this pull request.": return t('common:reviewPlatform.messages.reviewChatRequired'); + case "Review started, but its start acknowledgement is uncertain.": return t('common:reviewPlatform.messages.reviewUncertain'); + case "Token is required.": return t('common:reviewPlatform.messages.tokenRequired'); + case "This pull request could not be resolved from the remotes of the current workspace.": return t('common:reviewPlatform.messages.unresolvedPullRequest'); + case "GitHub pull request head SHA was not available.": return t('common:reviewPlatform.messages.headUnavailable'); + case "No matching GitHub Actions job was found for this check run.": return t('common:reviewPlatform.messages.noActionsJob'); + case "The matching GitHub Actions job does not expose a job id.": return t('common:reviewPlatform.messages.noActionsJobId'); + default: return undefined; + } +} + +export function reviewErrorText(message: string | null | undefined, t: ReviewTranslate): string { + return message ? localizedErrorText(message, t) ?? message : ""; +} + +export function reviewAuthErrorMessage(challenge: ReviewPlatformAuthChallenge, t: ReviewTranslate): string { + if (challenge.platform === 'github') { + return t('common:reviewPlatform.auth.github', { command: 'gh auth login --hostname ' + challenge.host }); + } + const options = { host: challenge.host }; + switch (challenge.state) { + case 'missing': return t('common:reviewPlatform.auth.missing', options); + case 'insufficient_scope': return t('common:reviewPlatform.auth.insufficientScope', options); + default: return t('common:reviewPlatform.auth.invalid', options); + } +} diff --git a/src/web-ui/src/infrastructure/api/errors/TauriCommandError.ts b/src/web-ui/src/infrastructure/api/errors/TauriCommandError.ts index 98356f2de0..8446deee28 100644 --- a/src/web-ui/src/infrastructure/api/errors/TauriCommandError.ts +++ b/src/web-ui/src/infrastructure/api/errors/TauriCommandError.ts @@ -192,3 +192,13 @@ export function gitRepositoryUntrustedPath(error: unknown): string | undefined { const payload = stableErrorPayload(error, GIT_REPOSITORY_UNTRUSTED_PREFIX); return payload ? payload : undefined; } + +/** Identifies missing Git in the environment executing the workspace. */ +export function isGitUnavailableError(error: unknown): boolean { + return hasStableErrorPrefix(error, 'git_unavailable:'); +} + +/** Stable Review-platform failure kind, preserved through transport wrappers. */ +export function reviewPlatformErrorCode(error: unknown): string | undefined { + return stableErrorPayload(error, 'review_platform_error:')?.split(':', 1)[0].trim(); +} diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index a1d4fb003d..18af42d862 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -534,6 +534,11 @@ "title": "Delete job \"{{name}}\"?" }, "messages": { + "reviewAvailable": "Review available · open to view", + "reviewFailedInspect": "Review failed · open to inspect", + "reviewResultUnloaded": "Review complete · open to load result", + "reviewResultUnavailable": "Review complete · result unavailable · open to inspect", + "reviewError": "Review error", "loadFailed": "Failed to load scheduled jobs: {{error}}", "saveFailed": "Failed to save scheduled job: {{error}}", "updateFailed": "Failed to update scheduled job: {{error}}", @@ -1903,6 +1908,56 @@ "minutesAgo": "{{count}}m ago", "hoursAgo": "{{count}}h ago", "daysAgo": "{{count}}d ago" + }, + "errors": { + "gitUnavailable": "Git is unavailable. Install Git and ensure it is on PATH in the environment running this workspace, then retry.", + "loadFailed": "Failed to load pull requests", + "invalidRepository": "Cannot read this Git repository. Check the workspace path and access permissions.", + "repositoryUntrusted": "Git does not trust this repository. Confirm its ownership before retrying.", + "remoteNotFound": "The selected remote no longer exists. Refresh and select a remote.", + "unsupportedPlatform": "This repository provider is not supported.", + "providerFailed": "The repository provider could not complete the request. Retry or check the provider service.", + "authenticationRequired": "Authentication is required. Sign in or update the token and retry.", + "permissionDenied": "Access was denied. Check the account and token permissions.", + "notFound": "The requested repository or item was not found, or is not accessible to this account.", + "networkFailed": "Could not connect to the repository provider. Check the network and retry.", + "invalidResponse": "The repository provider returned an unreadable response.", + "staleTarget": "The pull request changed. Refresh it before continuing the review.", + "evidenceTooLarge": "The provider evidence exceeds the supported size. Reduce the review scope and retry.", + "targetIsPullRequest": "This item is a pull request. Open it as a pull request instead of an issue." + }, + "messages": { + "noWorkspace": "No active workspace is available.", + "unsupportedLink": "This link is not a supported pull request URL.", + "detailsFailed": "Failed to load pull request details.", + "ciLogFailed": "Failed to load CI error log.", + "chatRequired": "Open or create a chat session before sending PR context.", + "reviewChatRequired": "Open or create a chat session before reviewing this pull request.", + "reviewUncertain": "Review started, but its start acknowledgement is uncertain.", + "reviewFailed": "Failed to start pull request Review.", + "tokenRequired": "Token is required.", + "saveTokenFailed": "Failed to save token.", + "clearTokenFailed": "Failed to clear token.", + "openAuthFailed": "Failed to open GitHub CLI authentication.", + "copyAuthFailed": "Failed to copy GitHub CLI login command.", + "unresolvedPullRequest": "This pull request could not be resolved from the remotes of the current workspace.", + "ghAuthRequired": "GitHub CLI authentication required", + "tokenRequiredTitle": "Token required", + "tokenScopeTitle": "Token permissions required", + "tokenUpdateTitle": "Token update required", + "headUnavailable": "GitHub pull request head SHA was not available.", + "noActionsJob": "No matching GitHub Actions job was found for this check run.", + "noActionsJobId": "The matching GitHub Actions job does not expose a job id.", + "authError": "Auth error", + "reviewFailedInspect": "Review failed · open to inspect", + "reviewResultUnavailable": "Review complete · result unavailable · open to inspect", + "reviewError": "Review error" + }, + "auth": { + "github": "Authenticate GitHub in the workspace environment, then retry: {{command}}", + "missing": "Add an access token for {{host}} to access this repository.", + "invalid": "The access token for {{host}} was rejected. Update it and retry.", + "insufficientScope": "The access token for {{host}} lacks required permissions. Update it and retry." } }, "dispatch": { diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 52cf0feaec..7d5d27d0e8 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -534,6 +534,11 @@ "title": "删除任务“{{name}}”?" }, "messages": { + "reviewAvailable": "审查可用,打开查看", + "reviewFailedInspect": "审查失败,打开查看详情", + "reviewResultUnloaded": "审查已完成,打开加载结果", + "reviewResultUnavailable": "审查已完成,但结果不可用,打开查看详情", + "reviewError": "审查出错", "loadFailed": "加载定时任务失败:{{error}}", "saveFailed": "保存定时任务失败:{{error}}", "updateFailed": "更新定时任务失败:{{error}}", @@ -1903,6 +1908,56 @@ "minutesAgo": "{{count}} 分钟前", "hoursAgo": "{{count}} 小时前", "daysAgo": "{{count}} 天前" + }, + "errors": { + "gitUnavailable": "Git 不可用。请在运行此工作区的环境中安装 Git 并将其加入 PATH,然后重试。", + "loadFailed": "加载拉取请求失败", + "invalidRepository": "无法读取此 Git 仓库,请检查工作区路径和访问权限。", + "repositoryUntrusted": "Git 尚未信任此仓库,请确认仓库所有权后重试。", + "remoteNotFound": "所选远程仓库已不存在,请刷新并重新选择。", + "unsupportedPlatform": "不支持此仓库提供方。", + "providerFailed": "仓库提供方无法完成请求,请重试或检查提供方服务。", + "authenticationRequired": "需要认证,请登录或更新令牌后重试。", + "permissionDenied": "访问被拒绝,请检查账号和令牌权限。", + "notFound": "未找到请求的仓库或条目,或当前账号无权访问。", + "networkFailed": "无法连接仓库提供方,请检查网络后重试。", + "invalidResponse": "仓库提供方返回了无法解析的响应。", + "staleTarget": "拉取请求已变化,请刷新后继续审查。", + "evidenceTooLarge": "提供方证据超出支持的大小,请缩小审查范围后重试。", + "targetIsPullRequest": "此条目是拉取请求,请按拉取请求打开,而不是按问题打开。" + }, + "messages": { + "noWorkspace": "没有可用的活动工作区。", + "unsupportedLink": "此链接不是受支持的拉取请求链接。", + "detailsFailed": "加载拉取请求详情失败。", + "ciLogFailed": "加载 CI 错误日志失败。", + "chatRequired": "请先打开或创建会话,再发送拉取请求上下文。", + "reviewChatRequired": "请先打开或创建会话,再审查此拉取请求。", + "reviewUncertain": "审查已发起,但尚未确认启动结果。", + "reviewFailed": "启动拉取请求审查失败。", + "tokenRequired": "请输入令牌。", + "saveTokenFailed": "保存令牌失败。", + "clearTokenFailed": "清除令牌失败。", + "openAuthFailed": "打开 GitHub CLI 认证失败。", + "copyAuthFailed": "复制 GitHub CLI 登录命令失败。", + "unresolvedPullRequest": "无法从当前工作区的远程仓库中找到此拉取请求。", + "ghAuthRequired": "需要 GitHub CLI 认证", + "tokenRequiredTitle": "需要令牌", + "tokenScopeTitle": "需要令牌权限", + "tokenUpdateTitle": "需要更新令牌", + "headUnavailable": "无法获取 GitHub 拉取请求的源提交 SHA。", + "noActionsJob": "未找到与此检查匹配的 GitHub Actions 任务。", + "noActionsJobId": "匹配的 GitHub Actions 任务未提供任务 ID。", + "authError": "认证错误", + "reviewFailedInspect": "审查失败 · 打开以查看详情", + "reviewResultUnavailable": "审查已完成 · 结果不可用 · 打开以查看详情", + "reviewError": "审查错误" + }, + "auth": { + "github": "请在工作区运行环境中完成 GitHub 认证后重试:{{command}}", + "missing": "请为 {{host}} 添加访问令牌以访问此仓库。", + "invalid": "{{host}} 的访问令牌被拒绝,请更新后重试。", + "insufficientScope": "{{host}} 的访问令牌权限不足,请更新后重试。" } }, "dispatch": { diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index f016a99eb5..2d4a1f8692 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -534,6 +534,11 @@ "title": "刪除任務“{{name}}”?" }, "messages": { + "reviewAvailable": "審查可用,開啟查看", + "reviewFailedInspect": "審查失敗,開啟查看詳細資料", + "reviewResultUnloaded": "審查已完成,開啟載入結果", + "reviewResultUnavailable": "審查已完成,但結果無法使用,開啟查看詳細資料", + "reviewError": "審查發生錯誤", "loadFailed": "載入定時任務失敗:{{error}}", "saveFailed": "儲存定時任務失敗:{{error}}", "updateFailed": "更新定時任務失敗:{{error}}", @@ -1903,6 +1908,56 @@ "minutesAgo": "{{count}} 分鐘前", "hoursAgo": "{{count}} 小時前", "daysAgo": "{{count}} 日前" + }, + "errors": { + "gitUnavailable": "Git 無法使用。請在執行此工作區的環境中安裝 Git 並將其加入 PATH,然後重試。", + "loadFailed": "載入提取請求失敗", + "invalidRepository": "無法讀取此 Git 儲存庫,請檢查工作區路徑與存取權限。", + "repositoryUntrusted": "Git 尚未信任此儲存庫,請確認儲存庫擁有權後重試。", + "remoteNotFound": "所選遠端儲存庫已不存在,請重新整理並重新選取。", + "unsupportedPlatform": "不支援此儲存庫提供者。", + "providerFailed": "儲存庫提供者無法完成請求,請重試或檢查提供者服務。", + "authenticationRequired": "需要驗證,請登入或更新權杖後重試。", + "permissionDenied": "存取遭拒,請檢查帳號與權杖權限。", + "notFound": "找不到請求的儲存庫或項目,或目前帳號無權存取。", + "networkFailed": "無法連線至儲存庫提供者,請檢查網路後重試。", + "invalidResponse": "儲存庫提供者傳回了無法解析的回應。", + "staleTarget": "提取請求已變更,請重新整理後繼續審查。", + "evidenceTooLarge": "提供者證據超出支援的大小,請縮小審查範圍後重試。", + "targetIsPullRequest": "此項目是提取請求,請以提取請求開啟,而非以問題開啟。" + }, + "messages": { + "noWorkspace": "沒有可用的作用中工作區。", + "unsupportedLink": "此連結不是支援的提取請求連結。", + "detailsFailed": "載入提取請求詳細資料失敗。", + "ciLogFailed": "載入 CI 錯誤記錄失敗。", + "chatRequired": "請先開啟或建立工作階段,再傳送提取請求上下文。", + "reviewChatRequired": "請先開啟或建立工作階段,再審查此提取請求。", + "reviewUncertain": "審查已發起,但尚未確認啟動結果。", + "reviewFailed": "啟動提取請求審查失敗。", + "tokenRequired": "請輸入權杖。", + "saveTokenFailed": "儲存權杖失敗。", + "clearTokenFailed": "清除權杖失敗。", + "openAuthFailed": "開啟 GitHub CLI 驗證失敗。", + "copyAuthFailed": "複製 GitHub CLI 登入命令失敗。", + "unresolvedPullRequest": "無法從目前工作區的遠端儲存庫中找到此提取請求。", + "ghAuthRequired": "需要 GitHub CLI 驗證", + "tokenRequiredTitle": "需要權杖", + "tokenScopeTitle": "需要權杖權限", + "tokenUpdateTitle": "需要更新權杖", + "headUnavailable": "無法取得 GitHub 提取請求的來源提交 SHA。", + "noActionsJob": "找不到與此檢查相符的 GitHub Actions 工作。", + "noActionsJobId": "相符的 GitHub Actions 工作未提供工作 ID。", + "authError": "驗證錯誤", + "reviewFailedInspect": "審查失敗 · 開啟以查看詳細資料", + "reviewResultUnavailable": "審查已完成 · 結果無法使用 · 開啟以查看詳細資料", + "reviewError": "審查錯誤" + }, + "auth": { + "github": "請在工作區執行環境中完成 GitHub 驗證後重試:{{command}}", + "missing": "請為 {{host}} 新增存取權杖以存取此儲存庫。", + "invalid": "{{host}} 的存取權杖遭拒,請更新後重試。", + "insufficientScope": "{{host}} 的存取權杖權限不足,請更新後重試。" } }, "dispatch": {