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/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/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 = (
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..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; @@ -926,11 +939,16 @@ export const ReviewActionBar: React.FC = ({ childSessionId onTouchMove={stopNestedScrollPropagation} > 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, 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 19961506a9..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,14 +35,42 @@ describe('ReviewActionHeader', () => { expect(html).toContain('aria-label="Minimize"'); }); - it('keeps only compact export actions in the top-right controls', () => { + 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 bddb434a55..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 @@ -6,6 +6,7 @@ type ExportableReviewData = React.ComponentProps void; } export const ReviewActionHeader: React.FC = ({ reviewData, + isReviewRunning = false, PhaseIcon, phaseIconClass, phaseTitle, errorMessage, + errorSummary, + errorDetailsLabel, minimizeLabel, onMinimize, }) => ( <>
- {reviewData && ( + {(reviewData || isReviewRunning) && ( = ({ className={`deep-review-action-bar__icon ${phaseIconClass}`} /> {phaseTitle} - {errorMessage && ( - {errorMessage} - )}
+ {errorMessage && ( +
+ {errorSummary && errorSummary !== errorMessage && ( +
{errorSummary}
+ )} + {errorDetailsLabel &&
{errorDetailsLabel}
} +
{errorMessage}
+
+ )} ); 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({ diff --git a/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.test.tsx b/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.test.tsx index b788e3f16f..a4689c59da 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.test.tsx @@ -88,6 +88,33 @@ describe('CodeReviewReportExportActions', () => { 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 : } /> 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": {