diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index e732ac529ba..bf35d6db0f5 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -79,7 +79,6 @@ inline src/renderer/src/components/LinearItemDrawer.tsx inline src/renderer/src/components/NewWorkspaceComposerCard.tsx inline src/renderer/src/components/TaskPage.tsx inline src/renderer/src/components/Terminal.tsx -inline src/renderer/src/components/UpdateCard.tsx inline src/renderer/src/components/WorktreeJumpPalette.tsx inline src/renderer/src/components/activity/ActivityPrototypePage.tsx inline src/renderer/src/components/automations/AutomationsPage.tsx @@ -99,8 +98,6 @@ inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx inline src/renderer/src/components/right-sidebar/PortsPanel.tsx inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx inline src/renderer/src/components/settings/AccountsPane.tsx -inline src/renderer/src/components/settings/AgentsPane.tsx -inline src/renderer/src/components/settings/RepositoryHooksSection.tsx inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx inline src/renderer/src/components/settings/Settings.tsx inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx diff --git a/src/renderer/src/components/UpdateCard.test.ts b/src/renderer/src/components/UpdateCard.test.ts index d5103ed30fc..a12146e9754 100644 --- a/src/renderer/src/components/UpdateCard.test.ts +++ b/src/renderer/src/components/UpdateCard.test.ts @@ -5,6 +5,10 @@ import type { ChangelogData, UpdateStatus } from '../../../shared/update-status- import { createUISlice } from '../store/slices/ui' import type { AppState } from '../store/types' import { isHttp2ProtocolError } from './UpdateCard' +import { + getUpdateCardAriaLabel, + isUpdateCardVisible +} from './maintenance/update-card/update-card-visibility' // ── Helpers ────────────────────────────────────────────────────────── @@ -319,35 +323,13 @@ type VisibilityInput = { type VisibilityResult = 'hidden' | 'visible' -/** Mirrors the visibility gates in UpdateCard's render path. */ function computeVisibility(input: VisibilityInput): VisibilityResult { - const { status, dismissedVersion, cachedVersion, hasStartedDownload } = input - const isUserInitiated = 'userInitiated' in status && status.userInitiated - const updateUserInitiatedCycle = input.updateUserInitiatedCycle ?? false - const shouldShowDetailedErrorCard = - status.state === 'error' && (hasStartedDownload || cachedVersion !== null) - - if (status.state === 'checking' && !isUserInitiated) { - return 'hidden' - } - if (status.state === 'not-available' && !isUserInitiated) { - return 'hidden' - } - if (status.state === 'idle') { - return 'hidden' - } - if (status.state === 'error' && !shouldShowDetailedErrorCard && !isUserInitiated) { - return 'hidden' - } - - const effectiveVersion = 'version' in status ? status.version : cachedVersion - if (effectiveVersion && dismissedVersion === effectiveVersion && !updateUserInitiatedCycle) { - if (status.state !== 'downloading' && status.state !== 'error') { - return 'hidden' - } - } - - return 'visible' + return isUpdateCardVisible({ + ...input, + updateUserInitiatedCycle: input.updateUserInitiatedCycle ?? false + }) + ? 'visible' + : 'hidden' } describe('UpdateCard visibility gates', () => { @@ -362,6 +344,10 @@ describe('UpdateCard visibility gates', () => { ).toBe('hidden') }) + it('uses the generic accessible label on idle', () => { + expect(getUpdateCardAriaLabel({ state: 'idle' })).toBe('Update status') + }) + it('hides background checking (not user-initiated)', () => { expect( computeVisibility({ diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index bb2b2d194c3..4bbf47fd492 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -1,94 +1,31 @@ -/* eslint-disable max-lines -- Why: keeps the updater state machine and its presentation variants in one file. */ import { useCallback, useEffect, useRef, useState } from 'react' +import { X } from 'lucide-react' +import type { ChangelogData } from '../../../shared/update-status-types' import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' import { useAppStore } from '../store' import { Card } from './ui/card' import { Button } from './ui/button' -import { Progress } from './ui/progress' -import { AlertCircle, Check, Loader2, Minus, X } from 'lucide-react' -import type { ChangelogData } from '../../../shared/update-status-types' -import { UpdateErrorCardContent, type UpdateErrorCardModel } from './UpdateErrorCardContent' -import { LinuxPackageInstallRecoveryCard } from './LinuxPackageInstallRecoveryCard' -import { - isWindowsSignatureCheckUnavailableFailure, - isWindowsSignatureMismatchFailure -} from '../../../shared/updater-windows-signature-check' -import { getReleaseNotesUrlForVersion } from '../../../shared/release-channel' import { translate } from '@/i18n/i18n' +import { UpdateCardStateContent } from './maintenance/update-card/UpdateCardStateContent' +import { buildUpdateCardErrorModel } from './maintenance/update-card/update-card-error-model' +import { + getUpdateCardAriaLabel, + isHttp2ProtocolError, + isUpdateCardVisible +} from './maintenance/update-card/update-card-visibility' -// ── Helpers ────────────────────────────────────────────────────────── +export { isHttp2ProtocolError } -function isAnimatedGif(url: string | undefined): boolean { - return typeof url === 'string' && url.toLowerCase().endsWith('.gif') -} - -export function isHttp2ProtocolError(message: string): boolean { - const normalized = message.toLowerCase() - return ( - normalized.includes('err_http2_protocol_error') || - normalized.includes('http2_protocol_error') || - (normalized.includes('http/2') && normalized.includes('protocol')) - ) -} - -// ── Compact card (transient check feedback) ───────────────────────── - -function CompactCardContent({ - icon, - text, - onClose, - action -}: { - icon: 'spinner' | 'check' | 'error' - text: string - onClose?: () => void - action?: { label: string; url: string } -}) { - return ( -
-
- {icon === 'spinner' && } - {icon === 'check' && } - {icon === 'error' && } -
-
-

{text}

- {action && ( - - )} -
- {onClose && ( - - )} -
- ) -} - -// ── Main component ────────────────────────────────────────────────── - -export function UpdateCard() { - const status = useAppStore((s) => s.updateStatus) - const storeChangelog = useAppStore((s) => s.updateChangelog) - const updateUserInitiatedCycle = useAppStore((s) => s.updateUserInitiatedCycle) - const dismissedVersion = useAppStore((s) => s.dismissedUpdateVersion) - const dismissUpdate = useAppStore((s) => s.dismissUpdate) - const collapsed = useAppStore((s) => s.updateCardCollapsed) - const setCollapsed = useAppStore((s) => s.setUpdateCardCollapsed) - const reassuranceSeen = useAppStore((s) => s.updateReassuranceSeen) - const markReassuranceSeen = useAppStore((s) => s.markUpdateReassuranceSeen) +export function UpdateCard(): React.JSX.Element | null { + const status = useAppStore((state) => state.updateStatus) + const changelog: ChangelogData | null = useAppStore((state) => state.updateChangelog) + const updateUserInitiatedCycle = useAppStore((state) => state.updateUserInitiatedCycle) + const dismissedVersion = useAppStore((state) => state.dismissedUpdateVersion) + const dismissUpdate = useAppStore((state) => state.dismissUpdate) + const collapsed = useAppStore((state) => state.updateCardCollapsed) + const setCollapsed = useAppStore((state) => state.setUpdateCardCollapsed) + const reassuranceSeen = useAppStore((state) => state.updateReassuranceSeen) + const markReassuranceSeen = useAppStore((state) => state.markUpdateReassuranceSeen) const hasStartedDownload = useRef(false) const dismissAnimationTimerRef = useRef(null) const collapseAnimationTimerRef = useRef(null) @@ -97,16 +34,10 @@ export function UpdateCard() { const [installError, setInstallError] = useState(null) const [compatibilityRelaunching, setCompatibilityRelaunching] = useState(false) const [compatibilitySetupError, setCompatibilitySetupError] = useState(null) - // Why: the dismiss gate keeps error cards visible, so a separate local flag tracks the error card's own X close. const [errorDismissed, setErrorDismissed] = useState(false) - // Why: local flag (not store) for the transient "up to date" auto-dismiss — no other component needs it. const [autoDismissed, setAutoDismissed] = useState(false) - // Tracks card exit so the fade-out animation plays before unmount. const [exiting, setExiting] = useState(false) - const changelog: ChangelogData | null = storeChangelog const isLocalBuild = status.source === 'local' - - // Why: the 'error' variant carries no version, but the card needs it for the fallback URL and dismiss; cache from states that have it. const versionRef = useRef(null) if ('version' in status && status.version) { versionRef.current = status.version @@ -115,11 +46,8 @@ export function UpdateCard() { status.state === 'idle' || status.state === 'not-available' ) { - // Why: clear the cached version so a later check failure can't link/dismiss against an unrelated older release. versionRef.current = null } - - // Why: reset component-local state on a new version so stale flags (media load, hasStartedDownload) don't leak forward. const prevVersionRef = useRef(null) if (status.state === 'available' && status.version !== prevVersionRef.current) { prevVersionRef.current = status.version @@ -128,8 +56,6 @@ export function UpdateCard() { setMediaLoaded(false) setInstallError(null) } - - // Why: reset per-cycle flags when a new status arrives so the card shows again next check cycle. const prevStateRef = useRef(status.state) if (status.state !== prevStateRef.current) { prevStateRef.current = status.state @@ -146,18 +72,13 @@ export function UpdateCard() { const shouldAutoDismissLatest = status.state === 'not-available' && 'userInitiated' in status && Boolean(status.userInitiated) - - // Auto-dismiss "You're on the latest version" after 3s; timer resets if status changes first. useEffect(() => { if (!shouldAutoDismissLatest) { return } - const timer = setTimeout(() => setAutoDismissed(true), 3000) - return () => clearTimeout(timer) + const timer = window.setTimeout(() => setAutoDismissed(true), 3000) + return () => window.clearTimeout(timer) }, [shouldAutoDismissLatest]) - - // Why: quitAndInstall must run in an effect, not render — StrictMode's double render would fire it twice. - // Gated on hasStartedDownload so Settings-initiated downloads don't auto-restart (user expects "Restart" there). useEffect(() => { if (status.state === 'downloaded' && hasStartedDownload.current) { void window.api.updater.quitAndInstall().catch((error) => { @@ -166,9 +87,7 @@ export function UpdateCard() { } }, [status.state]) - // ── Prefers-reduced-motion ────────────────────────────────────────── const prefersReducedMotion = usePrefersReducedMotion() - const clearAnimationTimers = useCallback(() => { if (dismissAnimationTimerRef.current !== null) { window.clearTimeout(dismissAnimationTimerRef.current) @@ -179,81 +98,38 @@ export function UpdateCard() { collapseAnimationTimerRef.current = null } }, []) - const cardRootRef = useCallback( (node: HTMLDivElement | null) => { - if (node !== null) { - return + if (node === null) { + clearAnimationTimers() } - // Why: cancel exit timers when the card surface unmounts so stale callbacks don't fire. - clearAnimationTimers() }, [clearAnimationTimers] ) - - // ── Visibility gates ────────────────────────────────────────────── - - const isUserInitiated = 'userInitiated' in status && status.userInitiated const cachedVersion = versionRef.current - const shouldShowDetailedErrorCard = - status.state === 'error' && (hasStartedDownload.current || cachedVersion !== null) - - // Compact transient states: only show for user-initiated checks. - if (status.state === 'checking' && !isUserInitiated) { - return null - } - if (status.state === 'not-available' && !isUserInitiated) { - return null - } - if (status.state === 'not-available' && autoDismissed) { - return null - } - - // Background states that never show the card. - if (status.state === 'idle') { - return null - } - - // Error: show for user-initiated failures or failures tied to a cached version; background failures stay silent. - if (status.state === 'error' && !shouldShowDetailedErrorCard && !isUserInitiated) { - return null - } - - // Why: the dismiss gate below keeps error cards visible, so an explicit X on the error card needs this gate to hide it. - if (status.state === 'error' && errorDismissed) { - return null - } - - // Dismiss gate: hide previously-dismissed versions for passive states, keep in-progress/error visible, and bypass for user-initiated checks. - if (versionRef.current && dismissedVersion === versionRef.current && !updateUserInitiatedCycle) { - if (status.state !== 'downloading' && status.state !== 'error') { - return null - } - } - if ( - collapsed && - (status.state === 'downloading' || status.state === 'downloaded' || status.state === 'error') + !isUpdateCardVisible({ + status, + dismissedVersion, + cachedVersion, + hasStartedDownload: hasStartedDownload.current, + updateUserInitiatedCycle, + autoDismissed, + errorDismissed, + collapsed + }) ) { return null } - // ── Shared helpers ──────────────────────────────────────────────── - - const isRichMode = changelog?.release != null - - const handleUpdate = () => { + const handleUpdate = (): void => { hasStartedDownload.current = true - // Why: clicking Update implies the user isn't worried about interruption, so retire the reassurance tip. if (!reassuranceSeen) { markReassuranceSeen() } void window.api.updater.download() } - - // Why: the 'error' variant has no version field, so dismiss needs an explicit version override. - const handleClose = () => { - // Why: dismissUpdate clears the store manual-check bypass so the dismiss gate re-engages after closing. + const handleClose = (): void => { if (status.state === 'error') { setErrorDismissed(true) if (cachedVersion) { @@ -263,16 +139,12 @@ export function UpdateCard() { } dismissUpdate() } - - const handleInstallRetry = () => { + const handleInstallRetry = (): void => { void window.api.updater.quitAndInstall().catch((error) => { setInstallError(String((error as Error)?.message ?? error)) }) } - - const handleEnableHttp1Compatibility = () => { - // Why: the shared error card marks a pending action aria-disabled rather than disabled, so the - // second click of a double-click now reaches this handler and would relaunch twice. + const handleEnableHttp1Compatibility = (): void => { if (compatibilityRelaunching) { return } @@ -288,132 +160,25 @@ export function UpdateCard() { setCompatibilityRelaunching(false) }) } - - // Why: order matters — the wrong-publisher security-stop must beat the "check couldn't run" case so integrity failures aren't softened to "try again". - const isHttp2UpdateError = status.state === 'error' && isHttp2ProtocolError(status.message) - const isSignatureMismatchError = - status.state === 'error' && isWindowsSignatureMismatchFailure(status.message) - const isSignatureCheckBlockedError = - status.state === 'error' && isWindowsSignatureCheckUnavailableFailure(status.message) - // Carries the diagnostic alongside the recovery so the render branch needs no second state check. + const errorCard = buildUpdateCardErrorModel({ + status, + isLocalBuild, + cachedVersion, + installError, + compatibilityRelaunching, + compatibilitySetupError, + onChooseLocalBuild: () => void window.api.updater.check({ localBuild: true }), + onEnableHttp1Compatibility: handleEnableHttp1Compatibility, + onRetryDownload: handleUpdate, + onRecheck: () => void window.api.updater.check({ includePrerelease: false }), + onInstallRetry: handleInstallRetry + }) const linuxPackageRecovery = status.state === 'error' && status.recovery?.kind === 'linux-package-install' ? { recovery: status.recovery, diagnostic: status.message } : null - const errorCard: UpdateErrorCardModel | null = - status.state === 'error' - ? isLocalBuild - ? { - title: cachedVersion - ? translate('auto.components.UpdateCard.8cf17b10af', 'Local Build Error') - : translate('auto.components.UpdateCard.a4650b0dc4', 'Could Not Use Local Build'), - summary: cachedVersion - ? translate( - 'auto.components.UpdateCard.b1e390250d', - 'Could not complete the local build switch.' - ) - : translate( - 'auto.components.UpdateCard.d29740d175', - 'The selected build could not be used.' - ), - detail: status.message, - primaryAction: { - label: translate('auto.components.UpdateCard.37d45c9ec1', 'Choose Another Build'), - onClick: () => { - void window.api.updater.check({ localBuild: true }) - } - } - } - : isHttp2UpdateError - ? { - variant: 'http1Compatibility', - title: translate('auto.components.UpdateCard.1339b82cee', 'HTTP/2 Download Blocked'), - summary: 'Orca can retry through HTTP/1.1 compatibility mode.', - explainer: translate( - 'auto.components.UpdateCard.90559b14e3', - 'This turns on a process-wide Electron networking switch after restart. Use it for corporate VPNs or proxies that reject HTTP/2 update downloads.' - ), - detail: compatibilitySetupError ?? status.message, - releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), - primaryAction: { - label: translate('auto.components.UpdateCard.933c6fdf5b', 'Enable & Restart'), - pendingLabel: 'Restarting...', - isPending: compatibilityRelaunching, - onClick: handleEnableHttp1Compatibility - } - } - : isSignatureMismatchError - ? { - // Security stop: installer signed by the wrong publisher — no retry, only a verified-download path. - variant: 'security', - title: translate( - 'auto.components.UpdateCard.5b309b19f3', - "Update Wasn't Installed" - ), - summary: translate( - 'auto.components.UpdateCard.092f09fc14', - "The installer's publisher doesn't match Orca, so we stopped the update. Don't install this download; check official releases for a corrected version." - ), - detail: status.message, - // Why: linking the rejected version would let users bypass the publisher check by re-running it. - releaseUrl: getReleaseNotesUrlForVersion(null), - manualLabel: translate( - 'auto.components.UpdateCard.c9ff9b9ec2', - 'Check official releases' - ) - } - : isSignatureCheckBlockedError - ? { - title: translate( - 'auto.components.UpdateCard.e944c2de43', - 'Update Verification Blocked' - ), - summary: translate( - 'auto.components.UpdateCard.a05992a26b', - "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases." - ), - detail: status.message, - releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), - primaryAction: { - label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), - onClick: handleUpdate - } - } - : { - // Why: title is scoped to the failed operation so check-time (GitHub-side) failures don't read as an Orca bug. - title: cachedVersion ? 'Update Error' : 'Update Check Failed', - summary: cachedVersion - ? 'Could not complete the update.' - : 'Could not check for updates.', - detail: status.message, - releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), - // Why: check-time failures are often transient, so offer a Re-check instead of forcing manual download. - primaryAction: cachedVersion - ? { - label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), - onClick: handleUpdate - } - : { - label: translate('auto.components.UpdateCard.6b0085010d', 'Re-check'), - onClick: () => { - void window.api.updater.check({ includePrerelease: false }) - } - } - } - : installError - ? { - title: translate('auto.components.UpdateCard.4cf109845a', 'Update Error'), - summary: 'Could not restart to install the update.', - detail: installError, - releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), - primaryAction: { - label: translate('auto.components.UpdateCard.2c2d3e03ca', 'Try Again'), - onClick: handleInstallRetry - } - } - : null - const handleDismissWithAnimation = () => { + const handleDismissWithAnimation = (): void => { if (prefersReducedMotion) { handleClose() return @@ -427,9 +192,7 @@ export function UpdateCard() { handleClose() }, 150) } - - // Why: dismissing an active download would orphan it, so long-running phases minimize to the status bar. - const handleCollapseWithAnimation = () => { + const handleCollapseWithAnimation = (): void => { if (prefersReducedMotion) { setCollapsed(true) return @@ -444,12 +207,11 @@ export function UpdateCard() { setExiting(false) }, 150) } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key !== 'Escape') { + const handleKeyDown = (event: React.KeyboardEvent): void => { + if (event.key !== 'Escape') { return } - e.preventDefault() + event.preventDefault() if ( status.state === 'downloading' || status.state === 'downloaded' || @@ -461,157 +223,38 @@ export function UpdateCard() { } } - // ── Dynamic aria-label ──────────────────────────────────────────── - - const ariaLabel = - status.state === 'checking' - ? 'Checking for updates' - : status.state === 'not-available' - ? "You're on the latest version" - : status.state === 'available' - ? 'Update available' - : status.state === 'downloading' - ? 'Downloading update' - : status.state === 'downloaded' - ? 'Update ready to install' - : status.state === 'error' - ? 'Update error' - : 'Update status' - - // ── Card wrapper ────────────────────────────────────────────────── + const cardContent = ( + setMediaFailed(true)} + onMediaLoad={() => setMediaLoaded(true)} + onUpdate={handleUpdate} + onInstallRetry={handleInstallRetry} + onDismiss={handleDismissWithAnimation} + onCollapse={handleCollapseWithAnimation} + /> + ) const animationClass = prefersReducedMotion ? '' : exiting ? 'animate-update-card-exit' : 'animate-update-card-enter' - - const cardContent = (() => { - // ── Compact transient states (user-initiated check feedback) ────── - - if (status.state === 'checking') { - return ( - - ) - } - - if (status.state === 'not-available') { - return ( - - ) - } - - // ── Error states ───────────────────────────────────────────────── - - // Why: the package-recovery card owns its own async validation state, so it branches before the generic models. - if (linuxPackageRecovery) { - return ( - - ) - } - - if (errorCard) { - return - } - - // ── Downloaded state ───────────────────────────────────────────── - - if (status.state === 'downloaded') { - if (hasStartedDownload.current) { - return ( -
-

- {translate('auto.components.UpdateCard.09a55c39b5', 'Installing...')} -

-
- ) - } - // Settings-initiated download — show "Ready to install" - return ( - - ) - } - - // ── Downloading state ──────────────────────────────────────────── - - if (status.state === 'downloading') { - return ( - setMediaFailed(true)} - onMediaLoad={() => setMediaLoaded(true)} - onCollapse={handleCollapseWithAnimation} - showReleaseNotes={!isLocalBuild} - /> - ) - } - - // ── Available state ────────────────────────────────────────────── - - if (status.state !== 'available') { - return null - } - - const releaseUrl = isLocalBuild - ? undefined - : (('releaseUrl' in status ? status.releaseUrl : undefined) ?? - getReleaseNotesUrlForVersion(status.version)) - - if (isRichMode && changelog) { - return ( - setMediaFailed(true)} - onMediaLoad={() => setMediaLoaded(true)} - onUpdate={handleUpdate} - onClose={handleDismissWithAnimation} - /> - ) - } - - return ( - - ) - })() - - // One-time reassurance tip that updating won't kill running terminals; persisted once seen. const showReassurance = !reassuranceSeen && (status.state === 'available' || status.state === 'downloading') - return (
{showReassurance && ( @@ -638,7 +281,7 @@ export function UpdateCard() { )} ) } - -// ── Rich card content ──────────────────────────────────────────────── - -function RichCardContent({ - release, - releasesBehind, - prefersReducedMotion, - mediaFailed, - mediaLoaded, - onMediaError, - onMediaLoad, - onUpdate, - onClose -}: { - release: NonNullable - releasesBehind: number | null - prefersReducedMotion: boolean - mediaFailed: boolean - mediaLoaded: boolean - onMediaError: () => void - onMediaLoad: () => void - onUpdate: () => void - onClose: () => void -}) { - const showMedia = - release.mediaUrl && - !mediaFailed && - // Why: GIFs can't be reliably paused cross-browser, so hide them entirely under reduced-motion. - !(prefersReducedMotion && isAnimatedGif(release.mediaUrl)) - - return ( -
-
-

- {translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title} -

- -
- - {showMedia && ( -
- {!mediaLoaded && ( - // Shimmer placeholder while image loads -
- )} - -
- )} - -

- {release.description} - {releasesBehind !== null && releasesBehind > 1 && ( - <> - {' '} - - - )} -

- - - - -
- ) -} - -// ── Simple card content ────────────────────────────────────────────── - -function SimpleCardContent({ - version, - releaseUrl, - onUpdate, - onClose -}: { - version: string - releaseUrl?: string - onUpdate: () => void - onClose: () => void -}) { - return ( -
-
-

- {translate('auto.components.UpdateCard.9abc59f814', 'Update Available')} -

- -
- -

- {translate('auto.components.UpdateCard.05ad78a6d1', 'Orca v{{value0}} is ready.', { - value0: version - })} -

- -

- {translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")} -

- - {releaseUrl && ( - - )} - - -
- ) -} - -// ── Downloading content ────────────────────────────────────────────── - -function DownloadingContent({ - version, - percent, - changelog, - prefersReducedMotion, - mediaFailed, - mediaLoaded, - onMediaError, - onMediaLoad, - onCollapse, - showReleaseNotes -}: { - version: string - percent: number - changelog: ChangelogData | null - prefersReducedMotion: boolean - mediaFailed: boolean - mediaLoaded: boolean - onMediaError: () => void - onMediaLoad: () => void - onCollapse: () => void - showReleaseNotes: boolean -}) { - const release = changelog?.release - const showMedia = - release?.mediaUrl && !mediaFailed && !(prefersReducedMotion && isAnimatedGif(release.mediaUrl)) - - return ( -
-
- {release ? ( -

- {translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title} -

- ) : ( -

- {translate('auto.components.UpdateCard.558842597d', 'Downloading Update')} -

- )} - -
- - {showMedia && release?.mediaUrl && ( -
- {!mediaLoaded && ( -
- )} - -
- )} - -

- {release - ? release.description - : translate('auto.components.UpdateCard.93794ea932', 'Orca v{{value0}} is downloading.', { - value0: version - })} -

- - {showReleaseNotes && ( - - )} - -
- -

- {translate('auto.components.UpdateCard.6e45bfa2e0', 'Downloading...')} {percent}% -

-
-
- ) -} - -// ── Ready to install content ───────────────────────────────────────── - -function ReadyToInstallContent({ - version, - onRestart, - onClose -}: { - version: string - onRestart: () => void - onClose: () => void -}) { - return ( -
-
-

- {translate('auto.components.UpdateCard.17412483da', 'Ready to Install')} -

- -
- -

- {translate( - 'auto.components.UpdateCard.6714206e5a', - "Orca v{{value0}} is downloaded. Restart when you're ready.", - { value0: version } - )} -

- - -
- ) -} diff --git a/src/renderer/src/components/maintenance/update-card/UpdateAvailableCardContent.tsx b/src/renderer/src/components/maintenance/update-card/UpdateAvailableCardContent.tsx new file mode 100644 index 00000000000..b18c18537d1 --- /dev/null +++ b/src/renderer/src/components/maintenance/update-card/UpdateAvailableCardContent.tsx @@ -0,0 +1,151 @@ +import { X } from 'lucide-react' +import type { ChangelogData } from '../../../../../shared/update-status-types' +import { Button } from '../../ui/button' +import { translate } from '@/i18n/i18n' + +function isAnimatedGif(url: string | undefined): boolean { + return typeof url === 'string' && url.toLowerCase().endsWith('.gif') +} + +export function UpdateAvailableRichContent({ + release, + releasesBehind, + prefersReducedMotion, + mediaFailed, + mediaLoaded, + onMediaError, + onMediaLoad, + onUpdate, + onClose +}: { + release: NonNullable + releasesBehind: number | null + prefersReducedMotion: boolean + mediaFailed: boolean + mediaLoaded: boolean + onMediaError: () => void + onMediaLoad: () => void + onUpdate: () => void + onClose: () => void +}): React.JSX.Element { + const showMedia = + release.mediaUrl && !mediaFailed && !(prefersReducedMotion && isAnimatedGif(release.mediaUrl)) + return ( +
+
+

+ {translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title} +

+ +
+ {showMedia && ( +
+ {!mediaLoaded && ( +
+ )} + +
+ )} +

+ {release.description} + {releasesBehind !== null && releasesBehind > 1 && ( + <> + {' '} + + + )} +

+ + +
+ ) +} + +export function UpdateAvailableSimpleContent({ + version, + releaseUrl, + onUpdate, + onClose +}: { + version: string + releaseUrl?: string + onUpdate: () => void + onClose: () => void +}): React.JSX.Element { + return ( +
+
+

+ {translate('auto.components.UpdateCard.9abc59f814', 'Update Available')} +

+ +
+

+ {translate('auto.components.UpdateCard.05ad78a6d1', 'Orca v{{value0}} is ready.', { + value0: version + })} +

+

+ {translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")} +

+ {releaseUrl && ( + + )} + +
+ ) +} diff --git a/src/renderer/src/components/maintenance/update-card/UpdateCardStateContent.tsx b/src/renderer/src/components/maintenance/update-card/UpdateCardStateContent.tsx new file mode 100644 index 00000000000..3a501f574af --- /dev/null +++ b/src/renderer/src/components/maintenance/update-card/UpdateCardStateContent.tsx @@ -0,0 +1,141 @@ +import type { + ChangelogData, + LinuxPackageInstallRecovery, + UpdateStatus +} from '../../../../../shared/update-status-types' +import { getReleaseNotesUrlForVersion } from '../../../../../shared/release-channel' +import { UpdateErrorCardContent, type UpdateErrorCardModel } from '../../UpdateErrorCardContent' +import { LinuxPackageInstallRecoveryCard } from '../../LinuxPackageInstallRecoveryCard' +import { translate } from '@/i18n/i18n' +import { UpdateCheckFeedback } from './UpdateCheckFeedback' +import { + UpdateAvailableRichContent, + UpdateAvailableSimpleContent +} from './UpdateAvailableCardContent' +import { UpdateDownloadingContent, UpdateReadyToInstallContent } from './UpdateDownloadCardContent' + +export function UpdateCardStateContent({ + status, + changelog, + errorCard, + linuxPackageRecovery, + isLocalBuild, + cachedVersion, + hasStartedDownload, + prefersReducedMotion, + mediaFailed, + mediaLoaded, + onMediaError, + onMediaLoad, + onUpdate, + onInstallRetry, + onDismiss, + onCollapse +}: { + status: UpdateStatus + changelog: ChangelogData | null + errorCard: UpdateErrorCardModel | null + linuxPackageRecovery: { + recovery: LinuxPackageInstallRecovery + diagnostic: string + } | null + isLocalBuild: boolean + cachedVersion: string | null + hasStartedDownload: boolean + prefersReducedMotion: boolean + mediaFailed: boolean + mediaLoaded: boolean + onMediaError: () => void + onMediaLoad: () => void + onUpdate: () => void + onInstallRetry: () => void + onDismiss: () => void + onCollapse: () => void +}): React.JSX.Element | null { + if (status.state === 'checking') { + return ( + + ) + } + if (status.state === 'not-available') { + return ( + + ) + } + if (linuxPackageRecovery) { + return ( + + ) + } + if (errorCard) { + return + } + if (status.state === 'downloaded') { + return hasStartedDownload ? ( +
+

+ {translate('auto.components.UpdateCard.09a55c39b5', 'Installing...')} +

+
+ ) : ( + + ) + } + if (status.state === 'downloading') { + return ( + + ) + } + if (status.state !== 'available') { + return null + } + const releaseUrl = isLocalBuild + ? undefined + : (status.releaseUrl ?? getReleaseNotesUrlForVersion(status.version)) + return changelog?.release ? ( + + ) : ( + + ) +} diff --git a/src/renderer/src/components/maintenance/update-card/UpdateCheckFeedback.tsx b/src/renderer/src/components/maintenance/update-card/UpdateCheckFeedback.tsx new file mode 100644 index 00000000000..969886ae915 --- /dev/null +++ b/src/renderer/src/components/maintenance/update-card/UpdateCheckFeedback.tsx @@ -0,0 +1,48 @@ +import { AlertCircle, Check, Loader2, X } from 'lucide-react' +import { Button } from '../../ui/button' +import { translate } from '@/i18n/i18n' + +export function UpdateCheckFeedback({ + icon, + text, + onClose, + action +}: { + icon: 'spinner' | 'check' | 'error' + text: string + onClose?: () => void + action?: { label: string; url: string } +}): React.JSX.Element { + return ( +
+
+ {icon === 'spinner' && } + {icon === 'check' && } + {icon === 'error' && } +
+
+

{text}

+ {action && ( + + )} +
+ {onClose && ( + + )} +
+ ) +} diff --git a/src/renderer/src/components/maintenance/update-card/UpdateDownloadCardContent.tsx b/src/renderer/src/components/maintenance/update-card/UpdateDownloadCardContent.tsx new file mode 100644 index 00000000000..99752661b39 --- /dev/null +++ b/src/renderer/src/components/maintenance/update-card/UpdateDownloadCardContent.tsx @@ -0,0 +1,147 @@ +import { Minus } from 'lucide-react' +import type { ChangelogData } from '../../../../../shared/update-status-types' +import { getReleaseNotesUrlForVersion } from '../../../../../shared/release-channel' +import { Button } from '../../ui/button' +import { Progress } from '../../ui/progress' +import { translate } from '@/i18n/i18n' + +function isAnimatedGif(url: string | undefined): boolean { + return typeof url === 'string' && url.toLowerCase().endsWith('.gif') +} + +export function UpdateDownloadingContent({ + version, + percent, + changelog, + prefersReducedMotion, + mediaFailed, + mediaLoaded, + onMediaError, + onMediaLoad, + onCollapse, + showReleaseNotes +}: { + version: string + percent: number + changelog: ChangelogData | null + prefersReducedMotion: boolean + mediaFailed: boolean + mediaLoaded: boolean + onMediaError: () => void + onMediaLoad: () => void + onCollapse: () => void + showReleaseNotes: boolean +}): React.JSX.Element { + const release = changelog?.release + const showMedia = + release?.mediaUrl && !mediaFailed && !(prefersReducedMotion && isAnimatedGif(release.mediaUrl)) + return ( +
+
+ {release ? ( +

+ {translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title} +

+ ) : ( +

+ {translate('auto.components.UpdateCard.558842597d', 'Downloading Update')} +

+ )} + +
+ {showMedia && release?.mediaUrl && ( +
+ {!mediaLoaded && ( +
+ )} + +
+ )} +

+ {release + ? release.description + : translate('auto.components.UpdateCard.93794ea932', 'Orca v{{value0}} is downloading.', { + value0: version + })} +

+ {showReleaseNotes && ( + + )} +
+ +

+ {translate('auto.components.UpdateCard.6e45bfa2e0', 'Downloading...')} {percent}% +

+
+
+ ) +} + +export function UpdateReadyToInstallContent({ + version, + onRestart, + onClose +}: { + version: string + onRestart: () => void + onClose: () => void +}): React.JSX.Element { + return ( +
+
+

+ {translate('auto.components.UpdateCard.17412483da', 'Ready to Install')} +

+ +
+

+ {translate( + 'auto.components.UpdateCard.6714206e5a', + "Orca v{{value0}} is downloaded. Restart when you're ready.", + { value0: version } + )} +

+ +
+ ) +} diff --git a/src/renderer/src/components/maintenance/update-card/update-card-error-model.test.ts b/src/renderer/src/components/maintenance/update-card/update-card-error-model.test.ts new file mode 100644 index 00000000000..66885476dd6 --- /dev/null +++ b/src/renderer/src/components/maintenance/update-card/update-card-error-model.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest' +import type { UpdateStatus } from '../../../../../shared/update-status-types' +import { buildUpdateCardErrorModel } from './update-card-error-model' + +function build(status: UpdateStatus, isLocalBuild = false) { + return buildUpdateCardErrorModel({ + status, + isLocalBuild, + cachedVersion: '1.4.200', + installError: null, + compatibilityRelaunching: false, + compatibilitySetupError: null, + onChooseLocalBuild: vi.fn(), + onEnableHttp1Compatibility: vi.fn(), + onRetryDownload: vi.fn(), + onRecheck: vi.fn(), + onInstallRetry: vi.fn() + }) +} + +describe('update card error model precedence', () => { + it('keeps a local build failure out of platform download recovery', () => { + const model = build( + { + state: 'error', + source: 'local', + message: 'New version is not signed by the application owner' + }, + true + ) + expect(model?.title).toBe('Local Build Error') + expect(model?.primaryAction?.label).toBe('Choose Another Build') + expect(model?.releaseUrl).toBeUndefined() + }) + + it('routes publisher mismatch ahead of the generic retry model', () => { + const model = build({ + state: 'error', + message: 'New version 1.4.200 is not signed by the application owner: publisherNames: Orca' + }) + expect(model?.variant).toBe('security') + expect(model?.primaryAction).toBeUndefined() + expect(model?.manualLabel).toBe('Check official releases') + }) + + it('preserves the pending HTTP/1 compatibility recovery action', () => { + const onEnableHttp1Compatibility = vi.fn() + const model = buildUpdateCardErrorModel({ + status: { state: 'error', message: 'net::ERR_HTTP2_PROTOCOL_ERROR' }, + isLocalBuild: false, + cachedVersion: '1.4.200', + installError: null, + compatibilityRelaunching: true, + compatibilitySetupError: null, + onChooseLocalBuild: vi.fn(), + onEnableHttp1Compatibility, + onRetryDownload: vi.fn(), + onRecheck: vi.fn(), + onInstallRetry: vi.fn() + }) + expect(model?.variant).toBe('http1Compatibility') + expect(model?.primaryAction).toMatchObject({ + label: 'Enable & Restart', + pendingLabel: 'Restarting...', + isPending: true, + onClick: onEnableHttp1Compatibility + }) + }) +}) diff --git a/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts b/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts new file mode 100644 index 00000000000..37f34f30b6a --- /dev/null +++ b/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts @@ -0,0 +1,133 @@ +import type { UpdateStatus } from '../../../../../shared/update-status-types' +import type { UpdateErrorCardModel } from '../../UpdateErrorCardContent' +import { + isWindowsSignatureCheckUnavailableFailure, + isWindowsSignatureMismatchFailure +} from '../../../../../shared/updater-windows-signature-check' +import { getReleaseNotesUrlForVersion } from '../../../../../shared/release-channel' +import { translate } from '@/i18n/i18n' +import { isHttp2ProtocolError } from './update-card-visibility' + +export function buildUpdateCardErrorModel({ + status, + isLocalBuild, + cachedVersion, + installError, + compatibilityRelaunching, + compatibilitySetupError, + onChooseLocalBuild, + onEnableHttp1Compatibility, + onRetryDownload, + onRecheck, + onInstallRetry +}: { + status: UpdateStatus + isLocalBuild: boolean + cachedVersion: string | null + installError: string | null + compatibilityRelaunching: boolean + compatibilitySetupError: string | null + onChooseLocalBuild: () => void + onEnableHttp1Compatibility: () => void + onRetryDownload: () => void + onRecheck: () => void + onInstallRetry: () => void +}): UpdateErrorCardModel | null { + if (status.state !== 'error') { + return installError + ? { + title: translate('auto.components.UpdateCard.4cf109845a', 'Update Error'), + summary: 'Could not restart to install the update.', + detail: installError, + releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), + primaryAction: { + label: translate('auto.components.UpdateCard.2c2d3e03ca', 'Try Again'), + onClick: onInstallRetry + } + } + : null + } + if (isLocalBuild) { + return { + title: cachedVersion + ? translate('auto.components.UpdateCard.8cf17b10af', 'Local Build Error') + : translate('auto.components.UpdateCard.a4650b0dc4', 'Could Not Use Local Build'), + summary: cachedVersion + ? translate( + 'auto.components.UpdateCard.b1e390250d', + 'Could not complete the local build switch.' + ) + : translate( + 'auto.components.UpdateCard.d29740d175', + 'The selected build could not be used.' + ), + detail: status.message, + primaryAction: { + label: translate('auto.components.UpdateCard.37d45c9ec1', 'Choose Another Build'), + onClick: onChooseLocalBuild + } + } + } + if (isHttp2ProtocolError(status.message)) { + return { + variant: 'http1Compatibility', + title: translate('auto.components.UpdateCard.1339b82cee', 'HTTP/2 Download Blocked'), + summary: 'Orca can retry through HTTP/1.1 compatibility mode.', + explainer: translate( + 'auto.components.UpdateCard.90559b14e3', + 'This turns on a process-wide Electron networking switch after restart. Use it for corporate VPNs or proxies that reject HTTP/2 update downloads.' + ), + detail: compatibilitySetupError ?? status.message, + releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), + primaryAction: { + label: translate('auto.components.UpdateCard.933c6fdf5b', 'Enable & Restart'), + pendingLabel: 'Restarting...', + isPending: compatibilityRelaunching, + onClick: onEnableHttp1Compatibility + } + } + } + if (isWindowsSignatureMismatchFailure(status.message)) { + return { + variant: 'security', + title: translate('auto.components.UpdateCard.5b309b19f3', "Update Wasn't Installed"), + summary: translate( + 'auto.components.UpdateCard.092f09fc14', + "The installer's publisher doesn't match Orca, so we stopped the update. Don't install this download; check official releases for a corrected version." + ), + detail: status.message, + releaseUrl: getReleaseNotesUrlForVersion(null), + manualLabel: translate('auto.components.UpdateCard.c9ff9b9ec2', 'Check official releases') + } + } + if (isWindowsSignatureCheckUnavailableFailure(status.message)) { + return { + title: translate('auto.components.UpdateCard.e944c2de43', 'Update Verification Blocked'), + summary: translate( + 'auto.components.UpdateCard.a05992a26b', + "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases." + ), + detail: status.message, + releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), + primaryAction: { + label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), + onClick: onRetryDownload + } + } + } + return { + title: cachedVersion ? 'Update Error' : 'Update Check Failed', + summary: cachedVersion ? 'Could not complete the update.' : 'Could not check for updates.', + detail: status.message, + releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), + primaryAction: cachedVersion + ? { + label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), + onClick: onRetryDownload + } + : { + label: translate('auto.components.UpdateCard.6b0085010d', 'Re-check'), + onClick: onRecheck + } + } +} diff --git a/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts b/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts new file mode 100644 index 00000000000..038bf1542c6 --- /dev/null +++ b/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts @@ -0,0 +1,79 @@ +import type { UpdateStatus } from '../../../../../shared/update-status-types' + +export function isUpdateCardVisible({ + status, + dismissedVersion, + cachedVersion, + hasStartedDownload, + updateUserInitiatedCycle, + autoDismissed = false, + errorDismissed = false, + collapsed = false +}: { + status: UpdateStatus + dismissedVersion: string | null + cachedVersion: string | null + hasStartedDownload: boolean + updateUserInitiatedCycle: boolean + autoDismissed?: boolean + errorDismissed?: boolean + collapsed?: boolean +}): boolean { + const isUserInitiated = 'userInitiated' in status && Boolean(status.userInitiated) + const shouldShowDetailedErrorCard = + status.state === 'error' && (hasStartedDownload || cachedVersion !== null) + + if (status.state === 'checking' && !isUserInitiated) { + return false + } + if (status.state === 'not-available' && (!isUserInitiated || autoDismissed)) { + return false + } + if (status.state === 'idle') { + return false + } + if (status.state === 'error' && !shouldShowDetailedErrorCard && !isUserInitiated) { + return false + } + if (status.state === 'error' && errorDismissed) { + return false + } + + if (cachedVersion && dismissedVersion === cachedVersion && !updateUserInitiatedCycle) { + if (status.state !== 'downloading' && status.state !== 'error') { + return false + } + } + return !( + collapsed && + (status.state === 'downloading' || status.state === 'downloaded' || status.state === 'error') + ) +} + +export function getUpdateCardAriaLabel(status: UpdateStatus): string { + switch (status.state) { + case 'idle': + return 'Update status' + case 'checking': + return 'Checking for updates' + case 'not-available': + return "You're on the latest version" + case 'available': + return 'Update available' + case 'downloading': + return 'Downloading update' + case 'downloaded': + return 'Update ready to install' + case 'error': + return 'Update error' + } +} + +export function isHttp2ProtocolError(message: string): boolean { + const normalized = message.toLowerCase() + return ( + normalized.includes('err_http2_protocol_error') || + normalized.includes('http2_protocol_error') || + (normalized.includes('http/2') && normalized.includes('protocol')) + ) +} diff --git a/src/renderer/src/components/settings/AgentCatalogRow.tsx b/src/renderer/src/components/settings/AgentCatalogRow.tsx new file mode 100644 index 00000000000..d9f6c926c39 --- /dev/null +++ b/src/renderer/src/components/settings/AgentCatalogRow.tsx @@ -0,0 +1,249 @@ +import { useState } from 'react' +import { Check, ChevronDown, ExternalLink } from 'lucide-react' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { AgentIcon } from '@/lib/agent-catalog' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { SettingsBadge, SettingsSegmentedControl } from './SettingsFormControls' +import type { AgentSessionSourceHomeControl } from './codex-session-source-home-control' +import { AgentSessionSourceHomeInput } from './codex-session-source-home-control' +import { stringifyAgentDefaultEnvDraft } from './agent-default-env-draft' +import { + AgentCommandOverrideInput, + AgentDefaultArgsInput, + AgentDefaultEnvInput +} from './AgentLaunchDefaultsEditor' + +type AgentAvailability = 'enabled' | 'disabled' + +export function AgentAvailabilityControl({ + label, + isEnabled, + onSetEnabled +}: { + label: string + isEnabled: boolean + onSetEnabled: (enabled: boolean) => void +}): React.JSX.Element { + const value: AgentAvailability = isEnabled ? 'enabled' : 'disabled' + return ( + + value={value} + onChange={(next) => { + if (next !== value) { + onSetEnabled(next === 'enabled') + } + }} + ariaLabel={translate( + 'auto.components.settings.AgentsPane.1c9a9679ec', + '{{value0}} availability', + { value0: label } + )} + size="sm" + options={[ + { + value: 'enabled', + label: translate('auto.components.settings.AgentsPane.d4d2a45d63', 'Enabled') + }, + { + value: 'disabled', + label: translate('auto.components.settings.AgentsPane.8dc0192e48', 'Disabled') + } + ]} + /> + ) +} + +export type AgentCatalogRowProps = { + agentId: TuiAgent + label: string + homepageUrl: string + defaultCmd: string + defaultArgs: string + defaultEnv: Record + isDetected: boolean + isEnabled: boolean + isDefault: boolean + cmdOverride: string | undefined + argsOverride: string + envOverride: Record + onSetDefault: () => void + onSetEnabled: (enabled: boolean) => void + onSaveOverride: (value: string) => void + onSaveArgs: (value: string) => void + onSaveEnv: (value: Record) => void + sessionSourceHome?: AgentSessionSourceHomeControl +} + +export function AgentCatalogRow({ + agentId, + label, + homepageUrl, + defaultCmd, + defaultArgs, + defaultEnv, + isDetected, + isEnabled, + isDefault, + cmdOverride, + argsOverride, + envOverride, + onSetDefault, + onSetEnabled, + onSaveOverride, + onSaveArgs, + onSaveEnv, + sessionSourceHome +}: AgentCatalogRowProps): React.JSX.Element { + const envSummary = stringifyAgentDefaultEnvDraft(envOverride) + const defaultEnvSummary = stringifyAgentDefaultEnvDraft(defaultEnv) + const [cmdOpen, setCmdOpen] = useState( + Boolean(cmdOverride) || argsOverride !== defaultArgs || envSummary !== defaultEnvSummary + ) + + return ( +
+
+
+ +
+
+
+ {label} + {!isEnabled && ( + + {translate('auto.components.settings.AgentsPane.8dc0192e48', 'Disabled')} + + )} +
+
+ {cmdOverride ? ( + + {defaultCmd} + {cmdOverride} + + ) : ( + defaultCmd + )} + {argsOverride && {argsOverride}} + {envSummary && {envSummary}} +
+
+ +
+ +
+ {isDetected && isEnabled && ( + + )} +
+ + + +
+ {isDetected && ( + + )} +
+
+
+ + {isDetected && cmdOpen && ( +
+ +
+ +
+ {(defaultEnvSummary || envSummary) && ( +
+ +
+ )} + {sessionSourceHome && ( +
+ +
+ )} +

+ {translate( + 'auto.components.settings.AgentsPane.f9f127d664', + 'Override the binary path or name, and edit the default launch arguments or environment for this agent.' + )} +

+
+ )} +
+ ) +} diff --git a/src/renderer/src/components/settings/AgentDefaultSetting.tsx b/src/renderer/src/components/settings/AgentDefaultSetting.tsx new file mode 100644 index 00000000000..4f70577bf8f --- /dev/null +++ b/src/renderer/src/components/settings/AgentDefaultSetting.tsx @@ -0,0 +1,104 @@ +import { Check, Terminal } from 'lucide-react' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentCatalogEntry } from '@/lib/agent-catalog' +import { AgentIcon } from '@/lib/agent-catalog' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { SettingsSubsectionHeader } from './SettingsFormControls' + +function DefaultAgentPill({ + active, + onClick, + children, + title +}: { + active: boolean + onClick: () => void + children: React.ReactNode + title?: string +}): React.JSX.Element { + return ( + + ) +} + +export function AgentDefaultSetting({ + defaultAgent, + detectedIds, + enabledDetectedAgents, + catalog, + description, + onSetDefault +}: { + defaultAgent: TuiAgent | 'blank' | null + detectedIds: Set | null + enabledDetectedAgents: AgentCatalogEntry[] + catalog: AgentCatalogEntry[] + description: string + onSetDefault: (agent: TuiAgent | 'blank' | null) => void +}): React.JSX.Element { + const storedDefaultAgent = + defaultAgent !== null && defaultAgent !== 'blank' + ? catalog.find((agent) => agent.id === defaultAgent) + : undefined + const defaultAgentPills = + storedDefaultAgent && !enabledDetectedAgents.some((agent) => agent.id === storedDefaultAgent.id) + ? [...enabledDetectedAgents, storedDefaultAgent] + : enabledDetectedAgents + + return ( +
+ +
+ onSetDefault(null)}> + {defaultAgent === null && } + {translate('auto.components.settings.AgentsPane.92033495ff', 'Auto')} + + onSetDefault('blank')}> + + {translate('auto.components.settings.AgentsPane.110b74b022', 'No agent (blank terminal)')} + {defaultAgent === 'blank' && } + + {defaultAgentPills.map((agent) => { + const isActive = defaultAgent === agent.id + const isUndetected = detectedIds !== null && !detectedIds.has(agent.id) + return ( + onSetDefault(agent.id)} + title={ + isUndetected + ? translate( + 'auto.components.settings.AgentsPane.storedDefaultUndetected', + 'Saved as your default, but not detected right now' + ) + : undefined + } + > + + {agent.label} + {isActive && } + + ) + })} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/AgentDetectionCatalog.tsx b/src/renderer/src/components/settings/AgentDetectionCatalog.tsx new file mode 100644 index 00000000000..e00924d4d4f --- /dev/null +++ b/src/renderer/src/components/settings/AgentDetectionCatalog.tsx @@ -0,0 +1,178 @@ +import { AlertTriangle, RefreshCw } from 'lucide-react' +import type { AgentCatalogEntry } from '@/lib/agent-catalog' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { AgentCatalogRow, type AgentCatalogRowProps } from './AgentCatalogRow' +import { SettingsBadge, SettingsSubsectionHeader } from './SettingsFormControls' + +export function AgentDetectionCatalog({ + detectedAgents, + undetectedAgents, + detectionPending, + detectionFailed, + isRefreshing, + activeServerEnvironmentId, + activeServerName, + onRefresh, + getRowProps +}: { + detectedAgents: AgentCatalogEntry[] + undetectedAgents: AgentCatalogEntry[] + detectionPending: boolean + detectionFailed: boolean + isRefreshing: boolean + activeServerEnvironmentId: string | null + activeServerName: string | null + onRefresh: () => void + getRowProps: (agent: AgentCatalogEntry, isDetected: boolean) => AgentCatalogRowProps +}): React.JSX.Element { + return ( + <> + {detectedAgents.length === 0 && !detectionPending && !detectionFailed && ( +
+ + {translate( + 'auto.components.settings.AgentsPane.noAgentsDetected', + 'No agents detected. If one is installed, the probe may have timed out.' + )} + + +
+ )} + + {detectedAgents.length > 0 && ( +
+ + {translate('auto.components.settings.AgentsPane.02e0143be5', 'Installed')} + + {detectedAgents.length}{' '} + {translate('auto.components.settings.AgentsPane.ed3e110e61', 'detected')} + + {activeServerName ? ( + + {translate('auto.components.settings.AgentsPane.03e1a5081a', 'on {{value0}}', { + value0: activeServerName + })} + + ) : null} + + } + action={ + + } + /> +
+ {detectedAgents.map((agent) => ( + + ))} +
+
+ )} + + {undetectedAgents.length > 0 && ( +
+ + {translate( + 'auto.components.settings.AgentsPane.e8da2af684', + 'Available to install' + )} + + {undetectedAgents.length}{' '} + {translate('auto.components.settings.AgentsPane.024bd95089', 'agents')} + + + } + /> +
+ {undetectedAgents.map((agent) => ( + + ))} +
+
+ )} + + {detectionPending && !detectionFailed && ( +
+ {translate( + 'auto.components.settings.AgentsPane.d83834f5e6', + 'Detecting installed agents…' + )} +
+ )} + + {detectionFailed && ( +
+ + + {translate( + 'auto.components.settings.AgentsPane.remoteDetectionFailed', + 'Couldn’t detect installed agents. Check the host connection and try again.' + )} + + +
+ )} + + ) +} + +function RefreshButton({ + isRefreshing, + onRefresh +}: { + isRefreshing: boolean + onRefresh: () => void +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx new file mode 100644 index 00000000000..d6e51da9f49 --- /dev/null +++ b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx @@ -0,0 +1,216 @@ +import { useId, useState } from 'react' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { parseAgentDefaultEnvDraft, stringifyAgentDefaultEnvDraft } from './agent-default-env-draft' + +export function AgentCommandOverrideInput({ + defaultCmd, + cmdOverride, + onSaveOverride +}: { + defaultCmd: string + cmdOverride: string | undefined + onSaveOverride: (value: string) => void +}): React.JSX.Element { + const draftSeed = cmdOverride ?? defaultCmd + const [cmdDraft, setCmdDraft] = useState(draftSeed) + const commitCmd = (): void => { + const trimmed = cmdDraft.trim() + if (!trimmed || trimmed === defaultCmd) { + onSaveOverride('') + setCmdDraft(defaultCmd) + } else { + onSaveOverride(trimmed) + } + } + + return ( +
+ + {translate('auto.components.settings.AgentsPane.2e45ca29b6', 'Command')} + +
+ setCmdDraft(event.target.value)} + onBlur={commitCmd} + onKeyDown={(event) => { + if (event.key === 'Enter') { + commitCmd() + event.currentTarget.blur() + } + if (event.key === 'Escape') { + setCmdDraft(draftSeed) + event.currentTarget.blur() + } + }} + placeholder={defaultCmd} + spellCheck={false} + className="h-7 flex-1 font-mono text-xs" + /> + {cmdOverride && ( + + )} +
+
+ ) +} + +export function AgentDefaultArgsInput({ + defaultArgs, + argsOverride, + onSaveArgs +}: { + defaultArgs: string + argsOverride: string + onSaveArgs: (value: string) => void +}): React.JSX.Element { + const [argsDraft, setArgsDraft] = useState(argsOverride) + const commitArgs = (): void => onSaveArgs(argsDraft.trim()) + + return ( +
+ + {translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')} + +
+ setArgsDraft(event.target.value)} + onBlur={commitArgs} + onKeyDown={(event) => { + if (event.key === 'Enter') { + commitArgs() + event.currentTarget.blur() + } + if (event.key === 'Escape') { + setArgsDraft(argsOverride) + event.currentTarget.blur() + } + }} + placeholder={ + defaultArgs || + translate('auto.components.settings.AgentsPane.6f99bf5dd0', 'No default arguments') + } + spellCheck={false} + className="h-7 flex-1 font-mono text-xs" + /> + {argsOverride !== defaultArgs && ( + + )} +
+
+ ) +} + +export function AgentDefaultEnvInput({ + defaultEnv, + envOverride, + onSaveEnv +}: { + defaultEnv: Record + envOverride: Record + onSaveEnv: (value: Record) => void +}): React.JSX.Element { + const defaultEnvText = stringifyAgentDefaultEnvDraft(defaultEnv) + const draftSeed = stringifyAgentDefaultEnvDraft(envOverride) + const [envDraft, setEnvDraft] = useState(draftSeed) + const [envDraftTooLarge, setEnvDraftTooLarge] = useState(false) + const envDraftErrorId = useId() + const commitEnv = (): void => { + const parsedDraft = parseAgentDefaultEnvDraft(envDraft) + setEnvDraftTooLarge(parsedDraft.tooLarge) + if (!parsedDraft.tooLarge) { + onSaveEnv(parsedDraft.env) + } + } + + return ( +
+ + {translate('auto.components.settings.AgentsPane.8fbe1f37c1', 'Environment')} + +
+ { + setEnvDraft(event.target.value) + if (envDraftTooLarge) { + setEnvDraftTooLarge(false) + } + }} + onBlur={commitEnv} + onKeyDown={(event) => { + if (event.key === 'Enter') { + commitEnv() + event.currentTarget.blur() + } + if (event.key === 'Escape') { + setEnvDraft(draftSeed) + setEnvDraftTooLarge(false) + event.currentTarget.blur() + } + }} + placeholder={ + defaultEnvText || + translate('auto.components.settings.AgentsPane.2d133152fa', 'No default environment') + } + spellCheck={false} + aria-invalid={envDraftTooLarge || undefined} + aria-describedby={envDraftTooLarge ? envDraftErrorId : undefined} + className={cn( + 'h-7 flex-1 font-mono text-xs', + envDraftTooLarge && 'border-destructive/50 bg-destructive/5' + )} + /> + {draftSeed !== defaultEnvText && ( + + )} +
+ {envDraftTooLarge && ( +

+ {translate( + 'auto.components.settings.AgentsPane.3f1bdf3cb4', + 'Environment text is too large to parse safely.' + )} +

+ )} +
+ ) +} diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 95abad76b35..59e139973ad 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -1,39 +1,20 @@ -/* eslint-disable max-lines -- Why: the Agents pane keeps catalog rows, default - selection, per-agent controls, and runtime location together so settings - reconciliation stays visible in one file. */ -import { useId, useMemo, useState } from 'react' -import { - AlertTriangle, - Check, - ChevronDown, - ExternalLink, - Info, - RefreshCw, - Terminal -} from 'lucide-react' +import { useMemo } from 'react' +import { Info } from 'lucide-react' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TuiAgent } from '../../../../shared/tui-agent' -import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog' +import { getAgentCatalog } from '@/lib/agent-catalog' import { useDetectedAgents, type AgentDetectionTarget } from '@/hooks/useDetectedAgents' import { useAppStore } from '@/store' -import { Button } from '../ui/button' -import { Input } from '../ui/input' -import { cn } from '@/lib/utils' import { AgentAwakeSetting } from './AgentAwakeSetting' import { AgentCacheTimerSection } from './AgentCacheTimerSection' import { AgentRuntimeSetting } from './AgentRuntimeSetting' -import { - AgentSessionSourceHomeInput, - buildCodexSessionSourceHomeControl, - type AgentSessionSourceHomeControl -} from './codex-session-source-home-control' +import { buildCodexSessionSourceHomeControl } from './codex-session-source-home-control' import { getAgentGeneratedTabTitlesDescription, getAgentGeneratedTabTitlesTitle } from './agent-generated-tab-title-copy' import { getAgentStatusHooksDescription, getAgentStatusHooksTitle } from './agent-status-hooks-copy' import { - SettingsBadge, SettingsSegmentedControl, SettingsSubsectionHeader, SettingsSwitchRow @@ -56,10 +37,22 @@ import { import { getSettingOwnershipSummary } from './setting-ownership' import { translate } from '@/i18n/i18n' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' -import { parseAgentDefaultEnvDraft, stringifyAgentDefaultEnvDraft } from './agent-default-env-draft' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' +import { getAgentsPaneSearchEntries } from './agents-search' +import { + buildAgentAvailabilitySettingsUpdate, + createAgentAvailabilityUpdateQueue +} from './agent-availability-settings' +import { AgentAvailabilityControl, type AgentCatalogRowProps } from './AgentCatalogRow' +import { AgentDefaultSetting } from './AgentDefaultSetting' +import { AgentDetectionCatalog } from './AgentDetectionCatalog' -export { getAgentsPaneSearchEntries } from './agents-search' +export { + buildAgentAvailabilitySettingsUpdate, + createAgentAvailabilityUpdateQueue, + getAgentsPaneSearchEntries, + AgentAvailabilityControl +} type AgentsPaneProps = { settings: GlobalSettings @@ -70,145 +63,15 @@ type AgentsPaneProps = { wslCapabilitiesLoading?: boolean } -type AgentAvailabilityUpdateQueueOptions = { - getSettings: () => GlobalSettings | null | undefined - fallbackSettings: GlobalSettings - updateSettings: AgentsPaneProps['updateSettings'] - agentId: TuiAgent - enabled: boolean -} - -type AgentRowProps = { - agentId: TuiAgent - label: string - homepageUrl: string - defaultCmd: string - defaultArgs: string - defaultEnv: Record - isDetected: boolean - isEnabled: boolean - isDefault: boolean - cmdOverride: string | undefined - argsOverride: string - envOverride: Record - onSetDefault: () => void - onSetEnabled: (enabled: boolean) => void - onSaveOverride: (value: string) => void - onSaveArgs: (value: string) => void - onSaveEnv: (value: Record) => void - /** Codex-only: current runtime scope label + persisted history-source override. */ - sessionSourceHome?: AgentSessionSourceHomeControl -} - -type AgentCommandOverrideInputProps = { - defaultCmd: string - cmdOverride: string | undefined - onSaveOverride: (value: string) => void -} - -type AgentDefaultArgsInputProps = { - defaultArgs: string - argsOverride: string - onSaveArgs: (value: string) => void -} - -type AgentDefaultEnvInputProps = { - defaultEnv: Record - envOverride: Record - onSaveEnv: (value: Record) => void -} - -type AgentAvailability = 'enabled' | 'disabled' - -type AgentAvailabilityControlProps = { - label: string - isEnabled: boolean - onSetEnabled: (enabled: boolean) => void -} - -type AgentPermissionsSettingProps = { - mode: AgentPermissionMode - onChange: (mode: Exclude) => void -} - -export function buildAgentAvailabilitySettingsUpdate( - settings: Pick, - id: TuiAgent, - enabled: boolean -): Pick & Partial> { - const latestDisabled = normalizeDisabledTuiAgents(settings.disabledTuiAgents) - const nextDisabled = enabled - ? latestDisabled.filter((agent) => agent !== id) - : latestDisabled.includes(id) - ? latestDisabled - : [...latestDisabled, id] - - return { - disabledTuiAgents: nextDisabled, - ...(settings.defaultTuiAgent === id && !enabled ? { defaultTuiAgent: null } : {}) - } -} - -export function createAgentAvailabilityUpdateQueue(): ( - options: AgentAvailabilityUpdateQueueOptions -) => Promise { - let pendingUpdate: Promise = Promise.resolve() - - return ({ getSettings, fallbackSettings, updateSettings, agentId, enabled }) => { - // Why: serialize full-array replacements so each write sees the store after - // the previous IPC has reconciled, while preserving the user's requested state. - pendingUpdate = pendingUpdate - .catch(() => {}) - .then(() => - updateSettings( - buildAgentAvailabilitySettingsUpdate(getSettings() ?? fallbackSettings, agentId, enabled) - ) - ) - return pendingUpdate.then(() => undefined) - } -} - const enqueueAgentAvailabilityUpdate = createAgentAvailabilityUpdateQueue() -export function AgentAvailabilityControl({ - label, - isEnabled, - onSetEnabled -}: AgentAvailabilityControlProps): React.JSX.Element { - const value: AgentAvailability = isEnabled ? 'enabled' : 'disabled' - - return ( - - value={value} - onChange={(next) => { - if (next !== value) { - onSetEnabled(next === 'enabled') - } - }} - ariaLabel={translate( - 'auto.components.settings.AgentsPane.1c9a9679ec', - '{{value0}} availability', - { value0: label } - )} - size="sm" - options={[ - { - value: 'enabled', - label: translate('auto.components.settings.AgentsPane.d4d2a45d63', 'Enabled') - }, - { - value: 'disabled', - label: translate('auto.components.settings.AgentsPane.8dc0192e48', 'Disabled') - } - ]} - /> - ) -} - export function AgentPermissionsSetting({ mode, onChange -}: AgentPermissionsSettingProps): React.JSX.Element { +}: { + mode: AgentPermissionMode + onChange: (mode: Exclude) => void +}): React.JSX.Element { const visibleMode: Exclude = mode === 'manual' ? 'manual' : 'yolo' return (
@@ -275,419 +138,6 @@ export function AgentPermissionsSetting({ ) } -function AgentCommandOverrideInput({ - defaultCmd, - cmdOverride, - onSaveOverride -}: AgentCommandOverrideInputProps): React.JSX.Element { - const draftSeed = cmdOverride ?? defaultCmd - const [cmdDraft, setCmdDraft] = useState(draftSeed) - - const commitCmd = (): void => { - const trimmed = cmdDraft.trim() - if (!trimmed || trimmed === defaultCmd) { - onSaveOverride('') - setCmdDraft(defaultCmd) - } else { - onSaveOverride(trimmed) - } - } - - return ( -
- - {translate('auto.components.settings.AgentsPane.2e45ca29b6', 'Command')} - -
- setCmdDraft(e.target.value)} - onBlur={commitCmd} - onKeyDown={(e) => { - if (e.key === 'Enter') { - commitCmd() - e.currentTarget.blur() - } - if (e.key === 'Escape') { - setCmdDraft(draftSeed) - e.currentTarget.blur() - } - }} - placeholder={defaultCmd} - spellCheck={false} - className="h-7 flex-1 font-mono text-xs" - /> - {cmdOverride && ( - - )} -
-
- ) -} - -function AgentDefaultArgsInput({ - defaultArgs, - argsOverride, - onSaveArgs -}: AgentDefaultArgsInputProps): React.JSX.Element { - const draftSeed = argsOverride - const [argsDraft, setArgsDraft] = useState(draftSeed) - - const commitArgs = (): void => { - onSaveArgs(argsDraft.trim()) - } - - return ( -
- - {translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')} - -
- setArgsDraft(e.target.value)} - onBlur={commitArgs} - onKeyDown={(e) => { - if (e.key === 'Enter') { - commitArgs() - e.currentTarget.blur() - } - if (e.key === 'Escape') { - setArgsDraft(draftSeed) - e.currentTarget.blur() - } - }} - placeholder={ - defaultArgs || - translate('auto.components.settings.AgentsPane.6f99bf5dd0', 'No default arguments') - } - spellCheck={false} - className="h-7 flex-1 font-mono text-xs" - /> - {argsOverride !== defaultArgs && ( - - )} -
-
- ) -} - -function AgentDefaultEnvInput({ - defaultEnv, - envOverride, - onSaveEnv -}: AgentDefaultEnvInputProps): React.JSX.Element { - const defaultEnvText = stringifyAgentDefaultEnvDraft(defaultEnv) - const draftSeed = stringifyAgentDefaultEnvDraft(envOverride) - const [envDraft, setEnvDraft] = useState(draftSeed) - const [envDraftTooLarge, setEnvDraftTooLarge] = useState(false) - const envDraftErrorId = useId() - - const commitEnv = (): void => { - const parsedDraft = parseAgentDefaultEnvDraft(envDraft) - setEnvDraftTooLarge(parsedDraft.tooLarge) - if (parsedDraft.tooLarge) { - return - } - onSaveEnv(parsedDraft.env) - } - - return ( -
- - {translate('auto.components.settings.AgentsPane.8fbe1f37c1', 'Environment')} - -
- { - setEnvDraft(e.target.value) - if (envDraftTooLarge) { - setEnvDraftTooLarge(false) - } - }} - onBlur={commitEnv} - onKeyDown={(e) => { - if (e.key === 'Enter') { - commitEnv() - e.currentTarget.blur() - } - if (e.key === 'Escape') { - setEnvDraft(draftSeed) - setEnvDraftTooLarge(false) - e.currentTarget.blur() - } - }} - placeholder={ - defaultEnvText || - translate('auto.components.settings.AgentsPane.2d133152fa', 'No default environment') - } - spellCheck={false} - aria-invalid={envDraftTooLarge || undefined} - aria-describedby={envDraftTooLarge ? envDraftErrorId : undefined} - className={cn( - 'h-7 flex-1 font-mono text-xs', - envDraftTooLarge && 'border-destructive/50 bg-destructive/5' - )} - /> - {draftSeed !== defaultEnvText && ( - - )} -
- {envDraftTooLarge && ( -

- {translate( - 'auto.components.settings.AgentsPane.3f1bdf3cb4', - 'Environment text is too large to parse safely.' - )} -

- )} -
- ) -} - -function AgentRow({ - agentId, - label, - homepageUrl, - defaultCmd, - defaultArgs, - defaultEnv, - isDetected, - isEnabled, - isDefault, - cmdOverride, - argsOverride, - envOverride, - onSetDefault, - onSetEnabled, - onSaveOverride, - onSaveArgs, - onSaveEnv, - sessionSourceHome -}: AgentRowProps): React.JSX.Element { - const envSummary = stringifyAgentDefaultEnvDraft(envOverride) - const defaultEnvSummary = stringifyAgentDefaultEnvDraft(defaultEnv) - const [cmdOpen, setCmdOpen] = useState( - Boolean(cmdOverride) || argsOverride !== defaultArgs || envSummary !== defaultEnvSummary - ) - - return ( -
-
-
- -
- -
-
- {label} - {!isEnabled && ( - - {translate('auto.components.settings.AgentsPane.8dc0192e48', 'Disabled')} - - )} -
-
- {cmdOverride ? ( - - {defaultCmd} - {cmdOverride} - - ) : ( - defaultCmd - )} - {argsOverride && {argsOverride}} - {envSummary && {envSummary}} -
-
- -
- - -
- {isDetected && isEnabled && ( - - )} -
- - - - - -
- {isDetected && ( - - )} -
-
-
- - {isDetected && cmdOpen && ( -
- {/* Why: key by the persisted seed so settings changes reset the draft during reconciliation, not in a follow-up effect commit. */} - -
- -
- {(defaultEnvSummary || envSummary) && ( -
- -
- )} - {sessionSourceHome && ( -
- -
- )} -

- {translate( - 'auto.components.settings.AgentsPane.f9f127d664', - 'Override the binary path or name, and edit the default launch arguments or environment for this agent.' - )} -

-
- )} -
- ) -} - -type DefaultAgentPillProps = { - active: boolean - onClick: () => void - children: React.ReactNode - title?: string -} - -function DefaultAgentPill({ - active, - onClick, - children, - title -}: DefaultAgentPillProps): React.JSX.Element { - return ( - - ) -} - export function AgentsPane({ settings, updateSettings, @@ -696,10 +146,6 @@ export function AgentsPane({ wslDistros, wslCapabilitiesLoading }: AgentsPaneProps): React.JSX.Element { - // Why: the Active Server routes agent launches and provider checks through - // that server, so this pane must list what THAT host can launch — detecting - // on the client showed a Windows machine's agents while paired to a Linux - // server (the enable/disable/default toggles below stay client settings). const activeServerEnvironmentId = settings.activeRuntimeEnvironmentId?.trim() || null const agentDetectionTarget = useMemo( () => @@ -714,38 +160,32 @@ export function AgentsPane({ isRefreshing, refresh: refreshTargetAgents } = useDetectedAgents(agentDetectionTarget) - const refreshLocalAgents = useAppStore((s) => s.refreshDetectedAgents) - const activeServerName = useAppStore((s) => + const refreshLocalAgents = useAppStore((state) => state.refreshDetectedAgents) + const activeServerName = useAppStore((state) => activeServerEnvironmentId - ? (s.runtimeEnvironments.find((environment) => environment.id === activeServerEnvironmentId) - ?.name ?? null) + ? (state.runtimeEnvironments.find( + (environment) => environment.id === activeServerEnvironmentId + )?.name ?? null) : null ) - // Why: refresh re-spawns the target host's login shell to re-capture PATH - // (preflight:refreshAgents). This handles the "installed a new CLI, Orca - // doesn't see it yet" case without a restart. - const handleRefresh = (): void => { - void refreshTargetAgents() - } const detectedIds = useMemo | null>( () => (detectedList ? new Set(detectedList) : null), [detectedList] ) - + const catalog = getAgentCatalog() const defaultAgent = settings.defaultTuiAgent - const agentOwnership = getSettingOwnershipSummary('agentLaunchDefaults') const cmdOverrides = settings.agentCmdOverrides ?? {} const agentDefaultArgs = settings.agentDefaultArgs ?? {} const agentDefaultEnv = settings.agentDefaultEnv ?? {} - const agentPermissionMode = resolveAgentPermissionModeSummary({ - agentDefaultArgs, - agentDefaultEnv - }) const disabledAgents = normalizeDisabledTuiAgents(settings.disabledTuiAgents) - - const setDefault = (id: TuiAgent | 'blank' | null): void => { - updateSettings({ defaultTuiAgent: id }) - } + const detectedAgents = + detectedIds === null ? [] : catalog.filter((agent) => detectedIds.has(agent.id)) + const enabledDetectedAgents = detectedAgents.filter((agent) => + isTuiAgentEnabled(agent.id, disabledAgents) + ) + const undetectedAgents = catalog.filter( + (agent) => detectedIds !== null && !detectedIds.has(agent.id) + ) const setAgentEnabled = (id: TuiAgent, enabled: boolean): void => { void enqueueAgentAvailabilityUpdate({ @@ -756,343 +196,92 @@ export function AgentsPane({ enabled }) } - - const saveOverride = (id: TuiAgent, value: string): void => { - const next = { ...cmdOverrides } - if (value) { - next[id] = value - } else { - delete next[id] - } - updateSettings({ agentCmdOverrides: next }) - } - - const saveAgentArgs = (id: TuiAgent, value: string): void => { - updateSettings({ - agentDefaultArgs: { - ...agentDefaultArgs, - [id]: value - } - }) - } - - const saveAgentEnv = (id: TuiAgent, value: Record): void => { - updateSettings({ - agentDefaultEnv: { - ...agentDefaultEnv, - [id]: value - } - }) - } - - const saveAgentPermissionMode = (mode: Exclude): void => { - updateSettings( - applyAgentPermissionMode({ - mode, - agentDefaultArgs, - agentDefaultEnv - }) - ) - } - - // Why: null means detection is in flight, not "all agents are installed". - // Showing the full catalog here makes the default-agent picker flash invalid - // options while switching between Windows and WSL detection contexts. - const detectedAgents = - detectedIds === null ? [] : getAgentCatalog().filter((agent) => detectedIds.has(agent.id)) - const enabledDetectedAgents = detectedAgents.filter((agent) => - isTuiAgentEnabled(agent.id, disabledAgents) - ) - const undetectedAgents = getAgentCatalog().filter( - (a) => detectedIds !== null && !detectedIds.has(a.id) - ) - - // Why only `=== null`: the pill's own handler writes null, so lighting it up - // for a *stored* agent that merely is not detected right now made the - // already-checked pill destructive -- one click on it erased - // `defaultTuiAgent` (#15256). Detection is a transient fact; the stored value - // is not, and this control reports the stored value. - const isAutoDefault = defaultAgent === null - const isBlankDefault = defaultAgent === 'blank' - - // Why show an undetected default: when detection comes back empty there were - // no agent pills at all, so the stored choice was invisible AND unrecoverable - // -- nothing to click to put it back. Keeping it listed means a failed or - // slow probe cannot quietly cost the user their setting. - const storedDefaultAgent = - defaultAgent !== null && defaultAgent !== 'blank' - ? getAgentCatalog().find((agent) => agent.id === defaultAgent) - : undefined - const defaultAgentPills = - storedDefaultAgent && !enabledDetectedAgents.some((agent) => agent.id === storedDefaultAgent.id) - ? [...enabledDetectedAgents, storedDefaultAgent] - : enabledDetectedAgents + const getRowProps = ( + agent: (typeof catalog)[number], + isDetected: boolean + ): AgentCatalogRowProps => ({ + agentId: agent.id, + label: agent.label, + homepageUrl: agent.homepageUrl, + defaultCmd: agent.cmd, + defaultArgs: getTuiAgentDefaultArgs(agent.id), + defaultEnv: getTuiAgentDefaultEnv(agent.id), + isDetected, + isEnabled: isTuiAgentEnabled(agent.id, disabledAgents), + isDefault: isDetected && defaultAgent === agent.id, + cmdOverride: isDetected ? cmdOverrides[agent.id] : undefined, + argsOverride: resolveTuiAgentLaunchArgs(agent.id, agentDefaultArgs), + envOverride: resolveTuiAgentLaunchEnv(agent.id, agentDefaultEnv), + onSetDefault: isDetected ? () => updateSettings({ defaultTuiAgent: agent.id }) : () => {}, + onSetEnabled: (enabled) => setAgentEnabled(agent.id, enabled), + onSaveOverride: isDetected + ? (value) => { + const next = { ...cmdOverrides } + if (value) { + next[agent.id] = value + } else { + delete next[agent.id] + } + updateSettings({ agentCmdOverrides: next }) + } + : () => {}, + onSaveArgs: (value) => + updateSettings({ agentDefaultArgs: { ...agentDefaultArgs, [agent.id]: value } }), + onSaveEnv: (value) => + updateSettings({ agentDefaultEnv: { ...agentDefaultEnv, [agent.id]: value } }), + sessionSourceHome: + isDetected && agent.id === 'codex' + ? buildCodexSessionSourceHomeControl(settings, updateSettings) + : undefined + }) return (
-
- - -
- setDefault(null)}> - {isAutoDefault && } - {translate('auto.components.settings.AgentsPane.92033495ff', 'Auto')} - - - {/* Why: users who prefer to open a raw shell by default need a - first-class "no agent" choice here — without it, the Auto pill - is the closest option but silently launches the first detected - agent, which is the opposite of what they want. */} - setDefault('blank')}> - - {translate( - 'auto.components.settings.AgentsPane.110b74b022', - 'No agent (blank terminal)' - )} - {isBlankDefault && } - - - {defaultAgentPills.map((agent) => { - const isActive = defaultAgent === agent.id - const isUndetected = detectedIds !== null && !detectedIds.has(agent.id) - return ( - setDefault(agent.id)} - title={ - isUndetected - ? translate( - 'auto.components.settings.AgentsPane.storedDefaultUndetected', - 'Saved as your default, but not detected right now' - ) - : undefined - } - > - - {agent.label} - {isActive && } - - ) - })} -
-
- + updateSettings({ defaultTuiAgent: agent })} + /> - - - {!isPairedWebClientWindow() ? ( ) : null} - - - - - {detectedAgents.length === 0 && detectedIds !== null && !detectionFailed && ( -
- {/* Why here and not only inside Installed: that section renders under - `detectedAgents.length > 0`, so the only Refresh control vanished - in precisely the state that needs a retry (#15256). */} - - {translate( - 'auto.components.settings.AgentsPane.noAgentsDetected', - 'No agents detected. If one is installed, the probe may have timed out.' - )} - - -
- )} - - {detectedAgents.length > 0 && ( -
- - {translate('auto.components.settings.AgentsPane.02e0143be5', 'Installed')} - - {detectedAgents.length}{' '} - {translate('auto.components.settings.AgentsPane.ed3e110e61', 'detected')} - - {activeServerName ? ( - - {translate('auto.components.settings.AgentsPane.03e1a5081a', 'on {{value0}}', { - value0: activeServerName - })} - - ) : null} - - } - action={ - - } - /> - -
- {detectedAgents.map((agent) => ( - setDefault(agent.id)} - onSetEnabled={(enabled) => setAgentEnabled(agent.id, enabled)} - onSaveOverride={(v) => saveOverride(agent.id, v)} - onSaveArgs={(v) => saveAgentArgs(agent.id, v)} - onSaveEnv={(v) => saveAgentEnv(agent.id, v)} - sessionSourceHome={ - agent.id === 'codex' - ? buildCodexSessionSourceHomeControl(settings, updateSettings) - : undefined - } - /> - ))} -
-
- )} - - {undetectedAgents.length > 0 && ( -
- - {translate( - 'auto.components.settings.AgentsPane.e8da2af684', - 'Available to install' - )} - - {undetectedAgents.length}{' '} - {translate('auto.components.settings.AgentsPane.024bd95089', 'agents')} - - - } - /> - -
- {undetectedAgents.map((agent) => ( - {}} - onSetEnabled={(enabled) => setAgentEnabled(agent.id, enabled)} - onSaveOverride={() => {}} - onSaveArgs={(v) => saveAgentArgs(agent.id, v)} - onSaveEnv={(v) => saveAgentEnv(agent.id, v)} - /> - ))} -
-
- )} - - {detectedIds === null && !detectionFailed && ( -
- {translate( - 'auto.components.settings.AgentsPane.d83834f5e6', - 'Detecting installed agents…' - )} -
- )} - - {detectionFailed && ( -
- - - {translate( - 'auto.components.settings.AgentsPane.remoteDetectionFailed', - 'Couldn’t detect installed agents. Check the host connection and try again.' - )} - - -
- )} + + updateSettings(applyAgentPermissionMode({ mode, agentDefaultArgs, agentDefaultEnv })) + } + /> + void refreshTargetAgents()} + getRowProps={getRowProps} + />
) } -export function AgentStatusHooksSetting({ - settings, - updateSettings -}: AgentsPaneProps): React.JSX.Element { +export function AgentStatusHooksSetting({ settings, updateSettings }: AgentsPaneProps) { const enabled = settings.agentStatusHooksEnabled !== false return (
@@ -1100,21 +289,14 @@ export function AgentStatusHooksSetting({ label={getAgentStatusHooksTitle()} description={getAgentStatusHooksDescription()} checked={enabled} - onChange={() => - updateSettings({ - agentStatusHooksEnabled: !enabled - }) - } + onChange={() => updateSettings({ agentStatusHooksEnabled: !enabled })} ariaLabel={getAgentStatusHooksTitle()} />
) } -export function AgentGeneratedTabTitlesSetting({ - settings, - updateSettings -}: AgentsPaneProps): React.JSX.Element { +export function AgentGeneratedTabTitlesSetting({ settings, updateSettings }: AgentsPaneProps) { const enabled = settings.tabAutoGenerateTitle === true return (
@@ -1122,11 +304,7 @@ export function AgentGeneratedTabTitlesSetting({ label={getAgentGeneratedTabTitlesTitle()} description={getAgentGeneratedTabTitlesDescription()} checked={enabled} - onChange={() => - updateSettings({ - tabAutoGenerateTitle: !enabled - }) - } + onChange={() => updateSettings({ tabAutoGenerateTitle: !enabled })} ariaLabel={getAgentGeneratedTabTitlesTitle()} />
diff --git a/src/renderer/src/components/settings/RepositoryHookPolicySettings.tsx b/src/renderer/src/components/settings/RepositoryHookPolicySettings.tsx new file mode 100644 index 00000000000..a6bedb5949c --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryHookPolicySettings.tsx @@ -0,0 +1,315 @@ +import { ChevronRight } from 'lucide-react' +import type { + HookCommandSourcePolicy, + OrcaHooks, + SetupAgentStartupPolicy, + SetupRunPolicy +} from '../../../../shared/orca-yaml-hook-types' +import { translate } from '@/i18n/i18n' +import { matchesSettingsSearch } from './settings-search' +import { SettingsSwitch } from './SettingsFormControls' +import { RepositoryHooksYamlStatus } from './RepositoryHooksYamlStatus' + +type PolicyOption = { policy: Policy; label: string; description: string } + +function SegmentedPolicyToggle({ + options, + selected, + onSelect +}: { + options: PolicyOption[] + selected: Policy + onSelect: (policy: Policy) => void +}): React.JSX.Element { + return ( +
+ {options.map(({ policy, label, description }) => ( + + ))} +
+ ) +} + +function getSetupRunPolicyOptions(): PolicyOption[] { + return [ + { + policy: 'ask', + label: translate( + 'auto.components.settings.RepositoryHooksSection.e03d9a8f38', + 'Ask every time' + ), + description: translate( + 'auto.components.settings.RepositoryHooksSection.90b1f50137', + 'Prompt before running setup.' + ) + }, + { + policy: 'run-by-default', + label: translate( + 'auto.components.settings.RepositoryHooksSection.d3ef1ab247', + 'Run by default' + ), + description: translate( + 'auto.components.settings.RepositoryHooksSection.022ba10cf2', + 'Run setup automatically.' + ) + }, + { + policy: 'skip-by-default', + label: translate( + 'auto.components.settings.RepositoryHooksSection.15debc1fd9', + 'Skip by default' + ), + description: translate( + 'auto.components.settings.RepositoryHooksSection.99e3264a49', + 'Only run setup when chosen.' + ) + } + ] +} + +function getCommandSourcePolicyOptions(): PolicyOption[] { + return [ + { + policy: 'shared-only', + label: translate( + 'auto.components.settings.RepositoryHooksSection.d88b6ff88f', + 'orca.yaml only' + ), + description: translate( + 'auto.components.settings.RepositoryHooksSection.29397e8bbc', + 'Run only committed repo commands; ignore local commands.' + ) + }, + { + policy: 'local-only', + label: translate('auto.components.settings.RepositoryHooksSection.83dc78202a', 'Local only'), + description: translate( + 'auto.components.settings.RepositoryHooksSection.0e8b2a520d', + 'Ignore orca.yaml; run only your local commands.' + ) + }, + { + policy: 'run-both', + label: translate('auto.components.settings.RepositoryHooksSection.8d6c56bff8', 'Run both'), + description: translate( + 'auto.components.settings.RepositoryHooksSection.8561b0665f', + 'orca.yaml first, then your local commands.' + ) + } + ] +} + +export function RepositorySetupPolicySetting({ + setupRunPolicy, + setupAgentStartupPolicy, + onRunPolicyChange, + onStartupPolicyChange +}: { + setupRunPolicy: SetupRunPolicy + setupAgentStartupPolicy: SetupAgentStartupPolicy + onRunPolicyChange: (policy: SetupRunPolicy) => void + onStartupPolicyChange: (policy: SetupAgentStartupPolicy) => void +}): React.JSX.Element { + const options = getSetupRunPolicyOptions() + return ( +
+
+
+
+ {translate('auto.components.settings.RepositoryHooksSection.793dcee97d', 'When to run')} +
+

+ {translate( + 'auto.components.settings.RepositoryHooksSection.21fb607a87', + 'Default behavior when a new worktree is created.' + )} +

+
+ +
+
+
+
+ {translate( + 'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgent', + 'Wait for setup to complete before starting agent' + )} +
+

+ {translate( + 'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgentHelp', + 'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.' + )} +

+
+ + onStartupPolicyChange( + setupAgentStartupPolicy === 'wait-for-setup' ? 'start-immediately' : 'wait-for-setup' + ) + } + ariaLabel={translate( + 'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgent', + 'Wait for setup to complete before starting agent' + )} + /> +
+
+ ) +} + +function getCommandSourceLabel(policy: HookCommandSourcePolicy): string { + if (policy === 'shared-only') { + return translate('auto.components.settings.RepositoryHooksSection.d88b6ff88f', 'orca.yaml only') + } + if (policy === 'local-only') { + return translate('auto.components.settings.RepositoryHooksSection.83dc78202a', 'Local only') + } + return translate('auto.components.settings.RepositoryHooksSection.8d6c56bff8', 'Run both') +} + +export function RepositoryHookCommandSourceSetting({ + searchQuery, + selectedPolicy, + yamlState, + yamlHooks, + copiedTemplate, + isAdvancedOpen, + onSelectPolicy, + onCopyTemplate, + onAdvancedOpenChange +}: { + searchQuery: string + selectedPolicy: HookCommandSourcePolicy + yamlState: string + yamlHooks: OrcaHooks | null + copiedTemplate: boolean + isAdvancedOpen: boolean + onSelectPolicy: (policy: HookCommandSourcePolicy) => void + onCopyTemplate: () => void + onAdvancedOpenChange: (open: boolean) => void +}): React.JSX.Element { + const advancedMatchesSearch = + searchQuery.trim() !== '' && + matchesSettingsSearch(searchQuery, { + title: translate('auto.components.settings.RepositoryHooksSection.c9bc1bfd8f', 'Advanced'), + description: translate( + 'auto.components.settings.RepositoryHooksSection.610d90fdbd', + 'Command source and orca.yaml details.' + ), + keywords: [ + translate('auto.components.settings.RepositoryHooksSection.c5a55a2d2e', 'advanced'), + translate('auto.components.settings.RepositoryHooksSection.4611b78617', 'command source'), + translate('auto.components.settings.RepositoryHooksSection.39da2ae12f', 'orca.yaml'), + translate('auto.components.settings.RepositoryHooksSection.d2b3016c20', 'shared'), + translate('auto.components.settings.RepositoryHooksSection.2d03a514db', 'local'), + translate('auto.components.settings.RepositoryHooksSection.0518758f38', 'both'), + translate('auto.components.settings.RepositoryHooksSection.fac13f8c1e', 'authoritative') + ] + }) + const options = getCommandSourcePolicyOptions() + return ( +
{ + if (advancedMatchesSearch) { + event.currentTarget.open = true + } else { + onAdvancedOpenChange(event.currentTarget.open) + } + }} + > + { + if (advancedMatchesSearch) { + event.preventDefault() + } + }} + > +
+ +
+ {translate('auto.components.settings.RepositoryHooksSection.c9bc1bfd8f', 'Advanced')} +
+ + {translate( + 'auto.components.settings.RepositoryHooksSection.bbbd6e0bc4', + 'Command source & orca.yaml' + )} + +
+ + {getCommandSourceLabel(selectedPolicy)} + +
+
+
+
+

+ {translate( + 'auto.components.settings.RepositoryHooksSection.32fec28f5b', + 'Command Source' + )} +

+

+ {translate('auto.components.settings.RepositoryHooksSection.ac9038d2cc', 'When both')}{' '} + + {translate( + 'auto.components.settings.RepositoryHooksSection.39da2ae12f', + 'orca.yaml' + )} + {' '} + {translate( + 'auto.components.settings.RepositoryHooksSection.3397879bee', + 'and local commands exist, choose which run.' + )} +

+
+
+ {options.map(({ policy, label, description }) => ( + + ))} +
+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/settings/RepositoryHookScriptSetting.tsx b/src/renderer/src/components/settings/RepositoryHookScriptSetting.tsx new file mode 100644 index 00000000000..c6457140ffa --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryHookScriptSetting.tsx @@ -0,0 +1,280 @@ +import { useEffect, useRef, useState } from 'react' +import { AlertTriangle, Plus } from 'lucide-react' +import { Button } from '../ui/button' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' +import { translate } from '@/i18n/i18n' +import { getRepositoryHookScriptTextareaRows } from '@/lib/script-textarea-rows' +import type { + LocalCommandSourcePolicyNotice, + LocalHookField +} from './repository-hook-settings-draft' + +function getEnvVars(): { name: string; description: string }[] { + return [ + { + name: '$ORCA_ROOT_PATH', + description: translate( + 'auto.components.settings.RepositoryHooksSection.30952c4aa4', + 'Path to the main repo checkout. Useful for copying shared files, like .env, into a worktree.' + ) + }, + { + name: '$ORCA_WORKTREE_PATH', + description: translate( + 'auto.components.settings.RepositoryHooksSection.54c73d88d0', + 'Path to the worktree being created. Setup commands run from this directory.' + ) + }, + { + name: '$ORCA_WORKSPACE_NAME', + description: translate( + 'auto.components.settings.RepositoryHooksSection.0fa21e19ec', + 'Name of the workspace, usually based on the branch name.' + ) + } + ] +} + +function EnvVarChips(): React.JSX.Element { + const envVars = getEnvVars() + return ( +
+

+ {translate( + 'auto.components.settings.RepositoryHooksSection.b2b06c7ce8', + 'Available environment variables (hover for details):' + )} +

+ +
+ {envVars.map(({ name, description }) => ( + + + + {name} + + + + {description} + + + ))} +
+
+
+ ) +} + +function SaveIndicator({ status }: { status: 'idle' | 'saving' | 'saved' }) { + if (status === 'idle') { + return null + } + const isSaving = status === 'saving' + return ( + + + {isSaving + ? translate('auto.components.settings.RepositoryHooksSection.81057d5f71', 'Saving...') + : translate('auto.components.settings.RepositoryHooksSection.2b6356e744', 'Saved')} + + ) +} + +export function RepositoryHookScriptSetting({ + field, + value, + hasShared, + sharedScript, + onChange, + onCommit, + sectionId +}: { + field: LocalHookField + value: string + hasShared: boolean + sharedScript: string | undefined + onChange: (next: string) => void + onCommit: () => void + sectionId?: string +}): React.JSX.Element { + const [showLocal, setShowLocal] = useState(value.length > 0) + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle') + const lastValueRef = useRef(value) + const savedTimerRef = useRef(null) + useEffect(() => { + if (value === lastValueRef.current) { + return + } + lastValueRef.current = value + setSaveStatus('saving') + if (savedTimerRef.current !== null) { + window.clearTimeout(savedTimerRef.current) + } + savedTimerRef.current = window.setTimeout(() => { + setSaveStatus('saved') + savedTimerRef.current = window.setTimeout(() => { + setSaveStatus('idle') + savedTimerRef.current = null + }, 1500) + }, 250) + return () => { + if (savedTimerRef.current !== null) { + window.clearTimeout(savedTimerRef.current) + savedTimerRef.current = null + } + } + }, [value]) + + const showLocalEditor = showLocal || value.length > 0 || !hasShared + return ( +
+
+
{field.label}
+

{field.description}

+
+ + {hasShared ? ( +
+
+ + {translate('auto.components.settings.RepositoryHooksSection.39da2ae12f', 'orca.yaml')} + + {translate( + 'auto.components.settings.RepositoryHooksSection.f828e1de19', + '- shared with your team' + )} + + + + {translate('auto.components.settings.RepositoryHooksSection.b113344b6a', 'Edit')}{' '} + + {translate( + 'auto.components.settings.RepositoryHooksSection.39da2ae12f', + 'orca.yaml' + )} + {' '} + {translate( + 'auto.components.settings.RepositoryHooksSection.7e4427b4a2', + 'to change.' + )} + +
+
+            {sharedScript ?? ''}
+          
+
+ ) : null} + {showLocalEditor ? ( +
+
+ {hasShared ? ( + + {translate('auto.components.settings.RepositoryHooksSection.2d03a514db', 'local')} + + {translate( + 'auto.components.settings.RepositoryHooksSection.40a446ae16', + '- just for you, on this machine' + )} + + + ) : ( + + )} + +
+