refactor settings maintenance modules (#16169)

* refactor settings maintenance modules

* revert behavior changes smuggled into settings split

- hoist isAdvancedOpen state back into RepositoryHooksSection so it survives
  SearchableSetting unmount during settings search
- drop isComposing guards absent from the merge-base AgentsPane handlers
- restore merge-base JSX for the 'when one exists.' fragment (no separator)
This commit is contained in:
Neil
2026-08-24 20:57:37 -07:00
committed by GitHub
parent d74f469379
commit ec12a0e442
29 changed files with 3316 additions and 2890 deletions
-3
View File
@@ -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
+14 -28
View File
@@ -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({
+78 -739
View File
@@ -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 (
<div className="flex items-center gap-3 p-3">
<div className="shrink-0 text-muted-foreground">
{icon === 'spinner' && <Loader2 className="size-4 animate-spin" />}
{icon === 'check' && <Check className="size-4" />}
{icon === 'error' && <AlertCircle className="size-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{text}</p>
{action && (
<button
className="text-xs text-muted-foreground underline hover:text-foreground mt-0.5"
onClick={() => void window.api.shell.openUrl(action.url)}
>
{action.label}
</button>
)}
</div>
{onClose && (
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={onClose}
aria-label={translate('auto.components.UpdateCard.a726967bd3', 'Dismiss')}
>
<X className="size-3.5" />
</Button>
)}
</div>
)
}
// ── 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<number | null>(null)
const collapseAnimationTimerRef = useRef<number | null>(null)
@@ -97,16 +34,10 @@ export function UpdateCard() {
const [installError, setInstallError] = useState<string | null>(null)
const [compatibilityRelaunching, setCompatibilityRelaunching] = useState(false)
const [compatibilitySetupError, setCompatibilitySetupError] = useState<string | null>(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<string | null>(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<string | null>(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 = (
<UpdateCardStateContent
status={status}
changelog={changelog}
errorCard={errorCard}
linuxPackageRecovery={linuxPackageRecovery}
isLocalBuild={isLocalBuild}
cachedVersion={cachedVersion}
hasStartedDownload={hasStartedDownload.current}
prefersReducedMotion={prefersReducedMotion}
mediaFailed={mediaFailed}
mediaLoaded={mediaLoaded}
onMediaError={() => 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 (
<CompactCardContent
icon="spinner"
text={translate('auto.components.UpdateCard.ba5ffc949c', 'Checking for updates...')}
/>
)
}
if (status.state === 'not-available') {
return (
<CompactCardContent
icon="check"
text={translate('auto.components.UpdateCard.ea2a41adbe', "You're on the latest version.")}
/>
)
}
// ── Error states ─────────────────────────────────────────────────
// Why: the package-recovery card owns its own async validation state, so it branches before the generic models.
if (linuxPackageRecovery) {
return (
<LinuxPackageInstallRecoveryCard
recovery={linuxPackageRecovery.recovery}
diagnostic={linuxPackageRecovery.diagnostic}
releaseUrl={isLocalBuild ? undefined : getReleaseNotesUrlForVersion(cachedVersion)}
onClose={handleCollapseWithAnimation}
/>
)
}
if (errorCard) {
return <UpdateErrorCardContent {...errorCard} onClose={handleCollapseWithAnimation} />
}
// ── Downloaded state ─────────────────────────────────────────────
if (status.state === 'downloaded') {
if (hasStartedDownload.current) {
return (
<div className="p-4">
<p className="text-sm">
{translate('auto.components.UpdateCard.09a55c39b5', 'Installing...')}
</p>
</div>
)
}
// Settings-initiated download — show "Ready to install"
return (
<ReadyToInstallContent
version={status.version}
onRestart={handleInstallRetry}
onClose={handleCollapseWithAnimation}
/>
)
}
// ── Downloading state ────────────────────────────────────────────
if (status.state === 'downloading') {
return (
<DownloadingContent
version={status.version}
percent={status.percent}
changelog={changelog}
prefersReducedMotion={prefersReducedMotion}
mediaFailed={mediaFailed}
mediaLoaded={mediaLoaded}
onMediaError={() => 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 (
<RichCardContent
release={changelog.release}
releasesBehind={changelog.releasesBehind}
prefersReducedMotion={prefersReducedMotion}
mediaFailed={mediaFailed}
mediaLoaded={mediaLoaded}
onMediaError={() => setMediaFailed(true)}
onMediaLoad={() => setMediaLoaded(true)}
onUpdate={handleUpdate}
onClose={handleDismissWithAnimation}
/>
)
}
return (
<SimpleCardContent
version={status.version}
releaseUrl={releaseUrl}
onUpdate={handleUpdate}
onClose={handleDismissWithAnimation}
/>
)
})()
// One-time reassurance tip that updating won't kill running terminals; persisted once seen.
const showReassurance =
!reassuranceSeen && (status.state === 'available' || status.state === 'downloading')
return (
<div
ref={cardRootRef}
className="fixed bottom-10 right-4 z-40 w-[360px] max-w-[calc(100vw-32px)] flex flex-col gap-2
max-[480px]:left-4 max-[480px]:right-4 max-[480px]:w-auto"
className="fixed bottom-10 right-4 z-40 w-[360px] max-w-[calc(100vw-32px)] flex flex-col gap-2 max-[480px]:left-4 max-[480px]:right-4 max-[480px]:w-auto"
>
{showReassurance && (
<Card className={`py-0 gap-0 ${animationClass}`}>
@@ -638,7 +281,7 @@ export function UpdateCard() {
)}
<Card
role="complementary"
aria-label={ariaLabel}
aria-label={getUpdateCardAriaLabel(status)}
aria-live="polite"
tabIndex={-1}
onKeyDown={handleKeyDown}
@@ -649,307 +292,3 @@ export function UpdateCard() {
</div>
)
}
// ── Rich card content ────────────────────────────────────────────────
function RichCardContent({
release,
releasesBehind,
prefersReducedMotion,
mediaFailed,
mediaLoaded,
onMediaError,
onMediaLoad,
onUpdate,
onClose
}: {
release: NonNullable<ChangelogData['release']>
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 (
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title}
</h3>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 min-w-[44px] min-h-[44px] -m-2"
onClick={onClose}
aria-label={translate('auto.components.UpdateCard.318d3b4bc7', 'Dismiss update')}
>
<X className="size-3.5" />
</Button>
</div>
{showMedia && (
<div className="relative overflow-hidden rounded-md">
{!mediaLoaded && (
// Shimmer placeholder while image loads
<div
className="w-full bg-muted/50 animate-pulse rounded-md"
style={{ aspectRatio: '16/9' }}
/>
)}
<img
src={release.mediaUrl}
alt=""
className={`w-full rounded-md ${mediaLoaded ? '' : 'absolute inset-0'}`}
style={!mediaLoaded ? { visibility: 'hidden' } : undefined}
onError={onMediaError}
onLoad={onMediaLoad}
/>
</div>
)}
<p className="text-sm text-muted-foreground">
{release.description}
{releasesBehind !== null && releasesBehind > 1 && (
<>
{' '}
<button
className="text-xs text-muted-foreground/70 underline hover:text-foreground inline"
onClick={() => void window.api.shell.openUrl(release.releaseNotesUrl)}
>
+{releasesBehind - 1}{' '}
{translate('auto.components.UpdateCard.ccd8b0a793', 'more since your last update')}
</button>
</>
)}
</p>
<button
className="text-xs text-muted-foreground underline hover:text-foreground self-start"
onClick={() => void window.api.shell.openUrl(release.releaseNotesUrl)}
>
{translate('auto.components.UpdateCard.aad383aecc', 'Read the full release notes')}
</button>
<Button variant="default" size="sm" onClick={onUpdate} className="w-full cursor-pointer">
{translate('auto.components.UpdateCard.ec8fe71cfc', 'Update')}
</Button>
</div>
)
}
// ── Simple card content ──────────────────────────────────────────────
function SimpleCardContent({
version,
releaseUrl,
onUpdate,
onClose
}: {
version: string
releaseUrl?: string
onUpdate: () => void
onClose: () => void
}) {
return (
<div className="flex flex-col gap-2.5 p-3.5">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.9abc59f814', 'Update Available')}
</h3>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 min-w-[44px] min-h-[44px] -m-2"
onClick={onClose}
aria-label={translate('auto.components.UpdateCard.318d3b4bc7', 'Dismiss update')}
>
<X className="size-3.5" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{translate('auto.components.UpdateCard.05ad78a6d1', 'Orca v{{value0}} is ready.', {
value0: version
})}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")}
</p>
{releaseUrl && (
<button
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground self-start"
onClick={() => void window.api.shell.openUrl(releaseUrl)}
>
{translate('auto.components.UpdateCard.44324ef542', 'Release notes')}
</button>
)}
<Button
variant="default"
size="sm"
onClick={onUpdate}
className="mt-0.5 w-full cursor-pointer"
>
{translate('auto.components.UpdateCard.ec8fe71cfc', 'Update')}
</Button>
</div>
)
}
// ── 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 (
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-2">
{release ? (
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title}
</h3>
) : (
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.558842597d', 'Downloading Update')}
</h3>
)}
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 min-w-[44px] min-h-[44px] -m-2"
onClick={onCollapse}
aria-label={translate('auto.components.UpdateCard.8acbdd3961', 'Minimize to status bar')}
>
<Minus className="size-3.5" />
</Button>
</div>
{showMedia && release?.mediaUrl && (
<div className="relative overflow-hidden rounded-md">
{!mediaLoaded && (
<div
className="w-full bg-muted/50 animate-pulse rounded-md"
style={{ aspectRatio: '16/9' }}
/>
)}
<img
src={release.mediaUrl}
alt=""
className={`w-full rounded-md ${mediaLoaded ? '' : 'absolute inset-0'}`}
style={!mediaLoaded ? { visibility: 'hidden' } : undefined}
onError={onMediaError}
onLoad={onMediaLoad}
/>
</div>
)}
<p className="text-sm text-muted-foreground">
{release
? release.description
: translate('auto.components.UpdateCard.93794ea932', 'Orca v{{value0}} is downloading.', {
value0: version
})}
</p>
{showReleaseNotes && (
<button
className="text-xs text-muted-foreground underline hover:text-foreground self-start"
onClick={() =>
void window.api.shell.openUrl(
release ? release.releaseNotesUrl : getReleaseNotesUrlForVersion(version)
)
}
>
{release
? translate('auto.components.UpdateCard.aad383aecc', 'Read the full release notes')
: translate('auto.components.UpdateCard.44324ef542', 'Release notes')}
</button>
)}
<div className="flex flex-col gap-2 mt-1">
<Progress value={percent} className="h-1.5" />
<p className="text-xs text-muted-foreground">
{translate('auto.components.UpdateCard.6e45bfa2e0', 'Downloading...')} {percent}%
</p>
</div>
</div>
)
}
// ── Ready to install content ─────────────────────────────────────────
function ReadyToInstallContent({
version,
onRestart,
onClose
}: {
version: string
onRestart: () => void
onClose: () => void
}) {
return (
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.17412483da', 'Ready to Install')}
</h3>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 min-w-[44px] min-h-[44px] -m-2"
onClick={onClose}
aria-label={translate('auto.components.UpdateCard.8acbdd3961', 'Minimize to status bar')}
>
<Minus className="size-3.5" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{translate(
'auto.components.UpdateCard.6714206e5a',
"Orca v{{value0}} is downloaded. Restart when you're ready.",
{ value0: version }
)}
</p>
<Button variant="default" size="sm" onClick={onRestart} className="w-full">
{translate('auto.components.UpdateCard.68b235d264', 'Restart to Update')}
</Button>
</div>
)
}
@@ -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<ChangelogData['release']>
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 (
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title}
</h3>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 min-w-[44px] min-h-[44px] -m-2"
onClick={onClose}
aria-label={translate('auto.components.UpdateCard.318d3b4bc7', 'Dismiss update')}
>
<X className="size-3.5" />
</Button>
</div>
{showMedia && (
<div className="relative overflow-hidden rounded-md">
{!mediaLoaded && (
<div
className="w-full bg-muted/50 animate-pulse rounded-md"
style={{ aspectRatio: '16/9' }}
/>
)}
<img
src={release.mediaUrl}
alt=""
className={`w-full rounded-md ${mediaLoaded ? '' : 'absolute inset-0'}`}
style={!mediaLoaded ? { visibility: 'hidden' } : undefined}
onError={onMediaError}
onLoad={onMediaLoad}
/>
</div>
)}
<p className="text-sm text-muted-foreground">
{release.description}
{releasesBehind !== null && releasesBehind > 1 && (
<>
{' '}
<button
type="button"
className="text-xs text-muted-foreground/70 underline hover:text-foreground inline"
onClick={() => void window.api.shell.openUrl(release.releaseNotesUrl)}
>
+{releasesBehind - 1}{' '}
{translate('auto.components.UpdateCard.ccd8b0a793', 'more since your last update')}
</button>
</>
)}
</p>
<button
type="button"
className="text-xs text-muted-foreground underline hover:text-foreground self-start"
onClick={() => void window.api.shell.openUrl(release.releaseNotesUrl)}
>
{translate('auto.components.UpdateCard.aad383aecc', 'Read the full release notes')}
</button>
<Button variant="default" size="sm" onClick={onUpdate} className="w-full cursor-pointer">
{translate('auto.components.UpdateCard.ec8fe71cfc', 'Update')}
</Button>
</div>
)
}
export function UpdateAvailableSimpleContent({
version,
releaseUrl,
onUpdate,
onClose
}: {
version: string
releaseUrl?: string
onUpdate: () => void
onClose: () => void
}): React.JSX.Element {
return (
<div className="flex flex-col gap-2.5 p-3.5">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.9abc59f814', 'Update Available')}
</h3>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 min-w-[44px] min-h-[44px] -m-2"
onClick={onClose}
aria-label={translate('auto.components.UpdateCard.318d3b4bc7', 'Dismiss update')}
>
<X className="size-3.5" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{translate('auto.components.UpdateCard.05ad78a6d1', 'Orca v{{value0}} is ready.', {
value0: version
})}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")}
</p>
{releaseUrl && (
<button
type="button"
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground self-start"
onClick={() => void window.api.shell.openUrl(releaseUrl)}
>
{translate('auto.components.UpdateCard.44324ef542', 'Release notes')}
</button>
)}
<Button
variant="default"
size="sm"
onClick={onUpdate}
className="mt-0.5 w-full cursor-pointer"
>
{translate('auto.components.UpdateCard.ec8fe71cfc', 'Update')}
</Button>
</div>
)
}
@@ -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 (
<UpdateCheckFeedback
icon="spinner"
text={translate('auto.components.UpdateCard.ba5ffc949c', 'Checking for updates...')}
/>
)
}
if (status.state === 'not-available') {
return (
<UpdateCheckFeedback
icon="check"
text={translate('auto.components.UpdateCard.ea2a41adbe', "You're on the latest version.")}
/>
)
}
if (linuxPackageRecovery) {
return (
<LinuxPackageInstallRecoveryCard
recovery={linuxPackageRecovery.recovery}
diagnostic={linuxPackageRecovery.diagnostic}
releaseUrl={isLocalBuild ? undefined : getReleaseNotesUrlForVersion(cachedVersion)}
onClose={onCollapse}
/>
)
}
if (errorCard) {
return <UpdateErrorCardContent {...errorCard} onClose={onCollapse} />
}
if (status.state === 'downloaded') {
return hasStartedDownload ? (
<div className="p-4">
<p className="text-sm">
{translate('auto.components.UpdateCard.09a55c39b5', 'Installing...')}
</p>
</div>
) : (
<UpdateReadyToInstallContent
version={status.version}
onRestart={onInstallRetry}
onClose={onCollapse}
/>
)
}
if (status.state === 'downloading') {
return (
<UpdateDownloadingContent
version={status.version}
percent={status.percent}
changelog={changelog}
prefersReducedMotion={prefersReducedMotion}
mediaFailed={mediaFailed}
mediaLoaded={mediaLoaded}
onMediaError={onMediaError}
onMediaLoad={onMediaLoad}
onCollapse={onCollapse}
showReleaseNotes={!isLocalBuild}
/>
)
}
if (status.state !== 'available') {
return null
}
const releaseUrl = isLocalBuild
? undefined
: (status.releaseUrl ?? getReleaseNotesUrlForVersion(status.version))
return changelog?.release ? (
<UpdateAvailableRichContent
release={changelog.release}
releasesBehind={changelog.releasesBehind}
prefersReducedMotion={prefersReducedMotion}
mediaFailed={mediaFailed}
mediaLoaded={mediaLoaded}
onMediaError={onMediaError}
onMediaLoad={onMediaLoad}
onUpdate={onUpdate}
onClose={onDismiss}
/>
) : (
<UpdateAvailableSimpleContent
version={status.version}
releaseUrl={releaseUrl}
onUpdate={onUpdate}
onClose={onDismiss}
/>
)
}
@@ -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 (
<div className="flex items-center gap-3 p-3">
<div className="shrink-0 text-muted-foreground">
{icon === 'spinner' && <Loader2 className="size-4 animate-spin" />}
{icon === 'check' && <Check className="size-4" />}
{icon === 'error' && <AlertCircle className="size-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{text}</p>
{action && (
<button
type="button"
className="text-xs text-muted-foreground underline hover:text-foreground mt-0.5"
onClick={() => void window.api.shell.openUrl(action.url)}
>
{action.label}
</button>
)}
</div>
{onClose && (
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={onClose}
aria-label={translate('auto.components.UpdateCard.a726967bd3', 'Dismiss')}
>
<X className="size-3.5" />
</Button>
)}
</div>
)
}
@@ -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 (
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-2">
{release ? (
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title}
</h3>
) : (
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.558842597d', 'Downloading Update')}
</h3>
)}
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 min-w-[44px] min-h-[44px] -m-2"
onClick={onCollapse}
aria-label={translate('auto.components.UpdateCard.8acbdd3961', 'Minimize to status bar')}
>
<Minus className="size-3.5" />
</Button>
</div>
{showMedia && release?.mediaUrl && (
<div className="relative overflow-hidden rounded-md">
{!mediaLoaded && (
<div
className="w-full bg-muted/50 animate-pulse rounded-md"
style={{ aspectRatio: '16/9' }}
/>
)}
<img
src={release.mediaUrl}
alt=""
className={`w-full rounded-md ${mediaLoaded ? '' : 'absolute inset-0'}`}
style={!mediaLoaded ? { visibility: 'hidden' } : undefined}
onError={onMediaError}
onLoad={onMediaLoad}
/>
</div>
)}
<p className="text-sm text-muted-foreground">
{release
? release.description
: translate('auto.components.UpdateCard.93794ea932', 'Orca v{{value0}} is downloading.', {
value0: version
})}
</p>
{showReleaseNotes && (
<button
type="button"
className="text-xs text-muted-foreground underline hover:text-foreground self-start"
onClick={() =>
void window.api.shell.openUrl(
release ? release.releaseNotesUrl : getReleaseNotesUrlForVersion(version)
)
}
>
{release
? translate('auto.components.UpdateCard.aad383aecc', 'Read the full release notes')
: translate('auto.components.UpdateCard.44324ef542', 'Release notes')}
</button>
)}
<div className="flex flex-col gap-2 mt-1">
<Progress value={percent} className="h-1.5" />
<p className="text-xs text-muted-foreground">
{translate('auto.components.UpdateCard.6e45bfa2e0', 'Downloading...')} {percent}%
</p>
</div>
</div>
)
}
export function UpdateReadyToInstallContent({
version,
onRestart,
onClose
}: {
version: string
onRestart: () => void
onClose: () => void
}): React.JSX.Element {
return (
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.17412483da', 'Ready to Install')}
</h3>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 min-w-[44px] min-h-[44px] -m-2"
onClick={onClose}
aria-label={translate('auto.components.UpdateCard.8acbdd3961', 'Minimize to status bar')}
>
<Minus className="size-3.5" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{translate(
'auto.components.UpdateCard.6714206e5a',
"Orca v{{value0}} is downloaded. Restart when you're ready.",
{ value0: version }
)}
</p>
<Button variant="default" size="sm" onClick={onRestart} className="w-full">
{translate('auto.components.UpdateCard.68b235d264', 'Restart to Update')}
</Button>
</div>
)
}
@@ -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
})
})
})
@@ -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
}
}
}
@@ -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'))
)
}
@@ -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 (
<SettingsSegmentedControl<AgentAvailability>
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<string, string>
isDetected: boolean
isEnabled: boolean
isDefault: boolean
cmdOverride: string | undefined
argsOverride: string
envOverride: Record<string, string>
onSetDefault: () => void
onSetEnabled: (enabled: boolean) => void
onSaveOverride: (value: string) => void
onSaveArgs: (value: string) => void
onSaveEnv: (value: Record<string, string>) => 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 (
<div className={cn('py-3', !isDetected && 'opacity-70')}>
<div className="flex flex-wrap items-start gap-3">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border/50 bg-background/50">
<AgentIcon agent={agentId} size={16} />
</div>
<div className="min-w-0 flex-1 sm:min-w-[12rem]">
<div className="flex items-center gap-2">
<span className="text-sm font-medium leading-none">{label}</span>
{!isEnabled && (
<SettingsBadge tone="muted">
{translate('auto.components.settings.AgentsPane.8dc0192e48', 'Disabled')}
</SettingsBadge>
)}
</div>
<div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
{cmdOverride ? (
<span>
<span className="text-muted-foreground/60 line-through">{defaultCmd}</span>
<span className="ml-1.5 text-foreground/80">{cmdOverride}</span>
</span>
) : (
defaultCmd
)}
{argsOverride && <span className="ml-1.5 text-foreground/70">{argsOverride}</span>}
{envSummary && <span className="ml-1.5 text-foreground/60">{envSummary}</span>}
</div>
</div>
<div className="ml-auto grid shrink-0 grid-cols-[max-content_6.5rem_1.75rem_1.75rem] items-center gap-1.5">
<AgentAvailabilityControl
label={label}
isEnabled={isEnabled}
onSetEnabled={onSetEnabled}
/>
<div className="flex justify-start">
{isDetected && isEnabled && (
<Button
type="button"
variant={isDefault ? 'secondary' : 'ghost'}
size="xs"
onClick={onSetDefault}
title={
isDefault
? translate('auto.components.settings.AgentsPane.d7625cf8b2', 'Default agent')
: translate('auto.components.settings.AgentsPane.5f986a9b92', 'Set as default')
}
className="h-7 w-full justify-center gap-1 text-xs"
>
{isDefault && <Check className="size-3" />}
{isDefault
? translate('auto.components.settings.AgentsPane.24e032fa34', 'Default')
: translate('auto.components.settings.AgentsPane.959b67385b', 'Set default')}
</Button>
)}
</div>
<a
href={homepageUrl}
target="_blank"
rel="noopener noreferrer"
title={
isDetected
? translate('auto.components.settings.AgentsPane.fe4d630c94', 'Docs')
: translate('auto.components.settings.AgentsPane.f95b5c79b8', 'Install')
}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
<ExternalLink className="size-3.5" />
</a>
<div className="flex size-7 items-center justify-center">
{isDetected && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setCmdOpen((previous) => !previous)}
aria-label={
cmdOpen
? translate(
'auto.components.settings.AgentsPane.cea7d97be1',
'Collapse command override'
)
: translate(
'auto.components.settings.AgentsPane.dc4a2ffdc0',
'Expand command override'
)
}
className="size-7 text-muted-foreground hover:text-foreground"
>
<ChevronDown
className={cn('size-3.5 transition-transform', cmdOpen && 'rotate-180')}
/>
</Button>
)}
</div>
</div>
</div>
{isDetected && cmdOpen && (
<div className="mt-3 pl-10">
<AgentCommandOverrideInput
key={cmdOverride ?? defaultCmd}
defaultCmd={defaultCmd}
cmdOverride={cmdOverride}
onSaveOverride={onSaveOverride}
/>
<div className="mt-2">
<AgentDefaultArgsInput
key={`${agentId}:${argsOverride}`}
defaultArgs={defaultArgs}
argsOverride={argsOverride}
onSaveArgs={onSaveArgs}
/>
</div>
{(defaultEnvSummary || envSummary) && (
<div className="mt-2">
<AgentDefaultEnvInput
key={`${agentId}:${envSummary}`}
defaultEnv={defaultEnv}
envOverride={envOverride}
onSaveEnv={onSaveEnv}
/>
</div>
)}
{sessionSourceHome && (
<div className="mt-2">
<AgentSessionSourceHomeInput
key={`${agentId}:${sessionSourceHome.runtimeLabel}:${sessionSourceHome.value}`}
runtimeLabel={sessionSourceHome.runtimeLabel}
value={sessionSourceHome.value}
onSave={sessionSourceHome.onSave}
/>
</div>
)}
<p className="mt-2 text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.AgentsPane.f9f127d664',
'Override the binary path or name, and edit the default launch arguments or environment for this agent.'
)}
</p>
</div>
)}
</div>
)
}
@@ -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 (
<button
type="button"
onClick={onClick}
aria-pressed={active}
title={title}
className={cn(
'inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
active
? 'border-muted-foreground/40 bg-accent font-medium text-accent-foreground'
: 'border-border bg-background/50 text-muted-foreground hover:border-muted-foreground/35 hover:text-foreground'
)}
>
{children}
</button>
)
}
export function AgentDefaultSetting({
defaultAgent,
detectedIds,
enabledDetectedAgents,
catalog,
description,
onSetDefault
}: {
defaultAgent: TuiAgent | 'blank' | null
detectedIds: Set<string> | 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 (
<section className="space-y-4">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AgentsPane.385212c7a1', 'Default Agent')}
description={description}
/>
<div className="flex flex-wrap gap-2">
<DefaultAgentPill active={defaultAgent === null} onClick={() => onSetDefault(null)}>
{defaultAgent === null && <Check className="size-3.5" />}
{translate('auto.components.settings.AgentsPane.92033495ff', 'Auto')}
</DefaultAgentPill>
<DefaultAgentPill active={defaultAgent === 'blank'} onClick={() => onSetDefault('blank')}>
<Terminal className="size-3.5" />
{translate('auto.components.settings.AgentsPane.110b74b022', 'No agent (blank terminal)')}
{defaultAgent === 'blank' && <Check className="size-3.5" />}
</DefaultAgentPill>
{defaultAgentPills.map((agent) => {
const isActive = defaultAgent === agent.id
const isUndetected = detectedIds !== null && !detectedIds.has(agent.id)
return (
<DefaultAgentPill
key={agent.id}
active={isActive}
onClick={() => onSetDefault(agent.id)}
title={
isUndetected
? translate(
'auto.components.settings.AgentsPane.storedDefaultUndetected',
'Saved as your default, but not detected right now'
)
: undefined
}
>
<AgentIcon agent={agent.id} size={14} />
{agent.label}
{isActive && <Check className="size-3.5" />}
</DefaultAgentPill>
)
})}
</div>
</section>
)
}
@@ -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 && (
<div className="flex items-center justify-between gap-3 rounded-md border border-dashed border-border/50 px-3 py-3 text-sm text-muted-foreground">
<span>
{translate(
'auto.components.settings.AgentsPane.noAgentsDetected',
'No agents detected. If one is installed, the probe may have timed out.'
)}
</span>
<RefreshButton isRefreshing={isRefreshing} onRefresh={onRefresh} />
</div>
)}
{detectedAgents.length > 0 && (
<section className="space-y-3">
<SettingsSubsectionHeader
title={
<span className="flex items-center gap-2">
{translate('auto.components.settings.AgentsPane.02e0143be5', 'Installed')}
<SettingsBadge tone="accent">
{detectedAgents.length}{' '}
{translate('auto.components.settings.AgentsPane.ed3e110e61', 'detected')}
</SettingsBadge>
{activeServerName ? (
<SettingsBadge tone="muted">
{translate('auto.components.settings.AgentsPane.03e1a5081a', 'on {{value0}}', {
value0: activeServerName
})}
</SettingsBadge>
) : null}
</span>
}
action={
<Button
type="button"
variant="ghost"
size="xs"
onClick={onRefresh}
disabled={isRefreshing}
title={
activeServerEnvironmentId
? translate(
'auto.components.settings.AgentsPane.25a41a9aad',
'Re-detect agents installed on the active server'
)
: translate(
'auto.components.settings.AgentsPane.13647f9f80',
'Re-read your shell PATH and re-detect installed agents'
)
}
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<RefreshCw className={cn('size-3', isRefreshing && 'animate-spin')} />
{isRefreshing
? translate('auto.components.settings.AgentsPane.c9b33eb5c0', 'Refreshing…')
: translate('auto.components.settings.AgentsPane.0d9e293a02', 'Refresh')}
</Button>
}
/>
<div className="divide-y divide-border/40">
{detectedAgents.map((agent) => (
<AgentCatalogRow key={agent.id} {...getRowProps(agent, true)} />
))}
</div>
</section>
)}
{undetectedAgents.length > 0 && (
<section className="space-y-3">
<SettingsSubsectionHeader
title={
<span className="flex items-center gap-2 text-muted-foreground">
{translate(
'auto.components.settings.AgentsPane.e8da2af684',
'Available to install'
)}
<SettingsBadge tone="muted">
{undetectedAgents.length}{' '}
{translate('auto.components.settings.AgentsPane.024bd95089', 'agents')}
</SettingsBadge>
</span>
}
/>
<div className="divide-y divide-border/40">
{undetectedAgents.map((agent) => (
<AgentCatalogRow key={agent.id} {...getRowProps(agent, false)} />
))}
</div>
</section>
)}
{detectionPending && !detectionFailed && (
<div className="flex items-center justify-center rounded-md border border-dashed border-border/50 py-6 text-sm text-muted-foreground">
{translate(
'auto.components.settings.AgentsPane.d83834f5e6',
'Detecting installed agents…'
)}
</div>
)}
{detectionFailed && (
<div className="flex items-start justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<span className="flex min-w-0 items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
{translate(
'auto.components.settings.AgentsPane.remoteDetectionFailed',
'Couldnt detect installed agents. Check the host connection and try again.'
)}
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={onRefresh}
className="h-6 shrink-0 gap-1.5 px-2 text-destructive hover:text-destructive"
>
<RefreshCw className="size-3" />
{translate('auto.components.settings.AgentsPane.retryDetection', 'Retry')}
</Button>
</div>
)}
</>
)
}
function RefreshButton({
isRefreshing,
onRefresh
}: {
isRefreshing: boolean
onRefresh: () => void
}): React.JSX.Element {
return (
<Button
type="button"
variant="ghost"
size="xs"
onClick={onRefresh}
disabled={isRefreshing}
className="h-7 shrink-0 gap-1.5 text-xs"
>
<RefreshCw className={cn('size-3', isRefreshing && 'animate-spin')} />
{isRefreshing
? translate('auto.components.settings.AgentsPane.c9b33eb5c0', 'Refreshing…')
: translate('auto.components.settings.AgentsPane.0d9e293a02', 'Refresh')}
</Button>
)
}
@@ -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 (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{translate('auto.components.settings.AgentsPane.2e45ca29b6', 'Command')}
</span>
<div className="flex items-center gap-2">
<Input
value={cmdDraft}
onChange={(event) => 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 && (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => {
onSaveOverride('')
setCmdDraft(defaultCmd)
}}
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
>
{translate('auto.components.settings.AgentsPane.5200dac9da', 'Reset')}
</Button>
)}
</div>
</div>
)
}
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 (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')}
</span>
<div className="flex items-center gap-2">
<Input
value={argsDraft}
onChange={(event) => 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 && (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => {
onSaveArgs(defaultArgs)
setArgsDraft(defaultArgs)
}}
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
>
{translate('auto.components.settings.AgentsPane.5200dac9da', 'Reset')}
</Button>
)}
</div>
</div>
)
}
export function AgentDefaultEnvInput({
defaultEnv,
envOverride,
onSaveEnv
}: {
defaultEnv: Record<string, string>
envOverride: Record<string, string>
onSaveEnv: (value: Record<string, string>) => 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 (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{translate('auto.components.settings.AgentsPane.8fbe1f37c1', 'Environment')}
</span>
<div className="flex items-center gap-2">
<Input
value={envDraft}
onChange={(event) => {
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 && (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => {
onSaveEnv(defaultEnv)
setEnvDraft(defaultEnvText)
setEnvDraftTooLarge(false)
}}
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
>
{translate('auto.components.settings.AgentsPane.5200dac9da', 'Reset')}
</Button>
)}
</div>
{envDraftTooLarge && (
<p id={envDraftErrorId} className="mt-1 text-[11px] text-destructive">
{translate(
'auto.components.settings.AgentsPane.3f1bdf3cb4',
'Environment text is too large to parse safely.'
)}
</p>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -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: Policy; label: string; description: string }
function SegmentedPolicyToggle<Policy extends string>({
options,
selected,
onSelect
}: {
options: PolicyOption<Policy>[]
selected: Policy
onSelect: (policy: Policy) => void
}): React.JSX.Element {
return (
<div className="inline-flex gap-0.5 rounded-lg border border-border/60 bg-muted/50 p-0.5">
{options.map(({ policy, label, description }) => (
<button
type="button"
key={policy}
onClick={() => onSelect(policy)}
title={description}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${selected === policy ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:bg-background/60 hover:text-foreground'}`}
>
{label}
</button>
))}
</div>
)
}
function getSetupRunPolicyOptions(): PolicyOption<SetupRunPolicy>[] {
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<HookCommandSourcePolicy>[] {
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 (
<div className="space-y-4 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h5 className="text-sm font-semibold">
{translate('auto.components.settings.RepositoryHooksSection.793dcee97d', 'When to run')}
</h5>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RepositoryHooksSection.21fb607a87',
'Default behavior when a new worktree is created.'
)}
</p>
</div>
<SegmentedPolicyToggle
options={options}
selected={setupRunPolicy}
onSelect={onRunPolicyChange}
/>
</div>
<div className="flex items-start justify-between gap-4 border-t border-border/60 pt-4">
<div className="min-w-0 space-y-1">
<h5 className="text-sm font-semibold">
{translate(
'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgent',
'Wait for setup to complete before starting agent'
)}
</h5>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgentHelp',
'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.'
)}
</p>
</div>
<SettingsSwitch
checked={setupAgentStartupPolicy === 'wait-for-setup'}
onChange={() =>
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'
)}
/>
</div>
</div>
)
}
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 (
<details
className="group rounded-2xl border border-border/50 bg-background/80 shadow-sm"
open={advancedMatchesSearch || isAdvancedOpen}
onToggle={(event) => {
if (advancedMatchesSearch) {
event.currentTarget.open = true
} else {
onAdvancedOpenChange(event.currentTarget.open)
}
}}
>
<summary
className="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 [&::-webkit-details-marker]:hidden"
onClick={(event) => {
if (advancedMatchesSearch) {
event.preventDefault()
}
}}
>
<div className="flex items-center gap-2">
<ChevronRight className="size-3.5 text-muted-foreground transition-transform group-open:rotate-90" />
<h5 className="text-sm font-semibold">
{translate('auto.components.settings.RepositoryHooksSection.c9bc1bfd8f', 'Advanced')}
</h5>
<span className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RepositoryHooksSection.bbbd6e0bc4',
'Command source & orca.yaml'
)}
</span>
</div>
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-[11px] font-medium text-foreground">
{getCommandSourceLabel(selectedPolicy)}
</span>
</summary>
<div className="space-y-5 border-t border-border/50 px-4 py-4">
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.RepositoryHooksSection.32fec28f5b',
'Command Source'
)}
</p>
<p className="text-[11px] text-muted-foreground">
{translate('auto.components.settings.RepositoryHooksSection.ac9038d2cc', 'When both')}{' '}
<code className="rounded bg-muted px-1 py-0.5">
{translate(
'auto.components.settings.RepositoryHooksSection.39da2ae12f',
'orca.yaml'
)}
</code>{' '}
{translate(
'auto.components.settings.RepositoryHooksSection.3397879bee',
'and local commands exist, choose which run.'
)}
</p>
</div>
<div className="grid gap-2 md:grid-cols-3">
{options.map(({ policy, label, description }) => (
<button
type="button"
key={policy}
onClick={() => onSelectPolicy(policy)}
className={`rounded-xl border px-3 py-2.5 text-center transition-colors ${selectedPolicy === policy ? 'border-foreground/15 bg-accent text-accent-foreground' : 'border-border/60 bg-background text-foreground hover:border-border hover:bg-muted/40'}`}
>
<span
className={`block text-sm ${selectedPolicy === policy ? 'font-semibold' : 'font-medium'}`}
>
{label}
</span>
<p
className={`mt-1 text-[11px] leading-4 ${selectedPolicy === policy ? 'text-accent-foreground/80' : 'text-muted-foreground'}`}
>
{description}
</p>
</button>
))}
</div>
</div>
<RepositoryHooksYamlStatus
yamlState={yamlState}
yamlHooks={yamlHooks}
copiedTemplate={copiedTemplate}
onCopyTemplate={onCopyTemplate}
/>
</div>
</details>
)
}
@@ -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 (
<div className="space-y-1.5">
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.RepositoryHooksSection.b2b06c7ce8',
'Available environment variables (hover for details):'
)}
</p>
<TooltipProvider delayDuration={150}>
<div className="flex flex-wrap gap-1.5">
{envVars.map(({ name, description }) => (
<Tooltip key={name}>
<TooltipTrigger asChild>
<code
tabIndex={0}
className="cursor-help rounded-md border border-border/50 bg-muted/35 px-2 py-1 font-mono text-[11px] text-muted-foreground outline-none transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{name}
</code>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="max-w-80 text-left text-wrap">
{description}
</TooltipContent>
</Tooltip>
))}
</div>
</TooltipProvider>
</div>
)
}
function SaveIndicator({ status }: { status: 'idle' | 'saving' | 'saved' }) {
if (status === 'idle') {
return null
}
const isSaving = status === 'saving'
return (
<span
className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground"
aria-live="polite"
>
<span
className={`size-1.5 rounded-full ${isSaving ? 'animate-pulse bg-amber-500' : 'bg-emerald-500'}`}
/>
{isSaving
? translate('auto.components.settings.RepositoryHooksSection.81057d5f71', 'Saving...')
: translate('auto.components.settings.RepositoryHooksSection.2b6356e744', 'Saved')}
</span>
)
}
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<number | null>(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 (
<div
className="space-y-3 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm"
id={sectionId}
>
<div className="space-y-1">
<h5 className="text-sm font-semibold">{field.label}</h5>
<p className="text-xs text-muted-foreground">{field.description}</p>
</div>
<EnvVarChips />
{hasShared ? (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/25 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
{translate('auto.components.settings.RepositoryHooksSection.39da2ae12f', 'orca.yaml')}
<span className="font-normal text-emerald-700/80 dark:text-emerald-300/80">
{translate(
'auto.components.settings.RepositoryHooksSection.f828e1de19',
'- shared with your team'
)}
</span>
</span>
<span className="text-[11px] text-muted-foreground">
{translate('auto.components.settings.RepositoryHooksSection.b113344b6a', 'Edit')}{' '}
<code className="rounded bg-muted px-1 py-0.5">
{translate(
'auto.components.settings.RepositoryHooksSection.39da2ae12f',
'orca.yaml'
)}
</code>{' '}
{translate(
'auto.components.settings.RepositoryHooksSection.7e4427b4a2',
'to change.'
)}
</span>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg border border-border/50 bg-muted/30 p-3 font-mono text-[11.5px] leading-5 text-foreground">
{sharedScript ?? ''}
</pre>
</div>
) : null}
{showLocalEditor ? (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
{hasShared ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/30 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{translate('auto.components.settings.RepositoryHooksSection.2d03a514db', 'local')}
<span className="font-normal">
{translate(
'auto.components.settings.RepositoryHooksSection.40a446ae16',
'- just for you, on this machine'
)}
</span>
</span>
) : (
<span />
)}
<SaveIndicator status={saveStatus} />
</div>
<textarea
value={value}
aria-label={field.label}
onChange={(event) => onChange(event.target.value)}
onBlur={onCommit}
placeholder={field.placeholder}
spellCheck={false}
rows={getRepositoryHookScriptTextareaRows(value)}
className="w-full min-w-0 resize-y rounded-lg border border-input bg-muted/20 px-3 py-2 font-mono text-[12px] leading-[1.55] shadow-xs transition-[color,box-shadow] outline-none placeholder:italic placeholder:text-muted-foreground/60 focus-visible:border-ring focus-visible:bg-background focus-visible:ring-[3px] focus-visible:ring-ring/40"
/>
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.RepositoryHooksSection.8c2893fae0',
'Runs as a single shell script. Saved on this machine.'
)}
</p>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowLocal(true)}
className="gap-1.5"
>
<Plus className="size-3.5" />
{translate(
'auto.components.settings.RepositoryHooksSection.5d940bde5c',
'Add local script'
)}
</Button>
)}
</div>
)
}
export function LocalCommandSourceNotice({
notice,
onSelectPolicy
}: {
notice: LocalCommandSourcePolicyNotice
onSelectPolicy: (policy: 'local-only' | 'run-both') => void
}): React.JSX.Element {
return (
<div className="flex flex-wrap items-start justify-between gap-3 rounded-xl border border-amber-500/20 bg-amber-500/5 p-3">
<div className="flex min-w-0 flex-1 items-start gap-3">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="space-y-1">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
{translate(
'auto.components.settings.RepositoryHooksSection.5426ecbdcb',
'Local scripts will not run'
)}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{notice.kind === 'checking'
? translate(
'auto.components.settings.RepositoryHooksSection.7f78e5eea6',
'Local scripts are saved. Orca is still checking orca.yaml before it can recommend which script source to use.'
)
: translate(
'auto.components.settings.RepositoryHooksSection.0ce113fd7b',
'Local scripts are saved, but Script Source is set to orca.yaml only.'
)}
</p>
</div>
</div>
{notice.kind === 'action' ? (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
onClick={() => onSelectPolicy(notice.policy)}
>
{notice.label}
</Button>
) : (
<span className="shrink-0 rounded-full border border-border/60 bg-muted/30 px-2 py-1 text-[11px] text-muted-foreground">
{translate('auto.components.settings.RepositoryHooksSection.673a7fd10e', 'Checking...')}
</span>
)}
</div>
)
}
@@ -4,6 +4,7 @@ import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
import { readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client'
import { getLocalCommandSourcePolicyNotice, RepositoryHooksSection } from './RepositoryHooksSection'
vi.mock('@/store', () => ({
@@ -31,6 +32,7 @@ const repo: Repo = {
function renderRepositoryHooksSection(args: {
onUpdateHookSettings: (settings: NonNullable<Repo['hookSettings']>) => void
repo?: Repo
}): { container: HTMLDivElement; root: Root } {
const container = document.createElement('div')
document.body.appendChild(container)
@@ -38,7 +40,7 @@ function renderRepositoryHooksSection(args: {
act(() => {
root.render(
React.createElement(RepositoryHooksSection, {
repo,
repo: args.repo ?? repo,
yamlHooks: null,
hasHooksFile: false,
hooksInspectionReady: true,
@@ -154,3 +156,22 @@ describe('RepositoryHooksSection setup startup policy', () => {
})
})
})
describe('RepositoryHooksSection execution ownership', () => {
it('routes issue-command reads through the repository runtime owner', async () => {
vi.mocked(readRuntimeIssueCommand).mockClear()
await act(async () => {
rendered = renderRepositoryHooksSection({
onUpdateHookSettings: () => {},
repo: { ...repo, executionHostId: 'runtime:hub' }
})
await Promise.resolve()
})
expect(readRuntimeIssueCommand).toHaveBeenCalledWith(
{ activeRuntimeEnvironmentId: 'hub' },
repo.id,
'runtime:hub'
)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,185 @@
import { AlertTriangle } from 'lucide-react'
import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types'
import { Button } from '../ui/button'
import { translate } from '@/i18n/i18n'
import { renderYamlScriptPreview } from './repository-hook-settings-draft'
const EXAMPLE_TEMPLATE = `scripts:
setup: |
pnpm worktree:setup
archive: |
echo "Cleaning up before archive"
issueCommand: |
Complete {{artifact_url}}`
const YAML_STATE_STYLES: Record<string, { card: string; titleClassName: string }> = {
loaded: {
card: 'border-emerald-500/20 bg-emerald-500/5',
titleClassName: 'text-emerald-700 dark:text-emerald-300'
},
'update-available': {
card: 'border-amber-500/20 bg-amber-500/5',
titleClassName: 'text-amber-700 dark:text-amber-300'
},
invalid: {
card: 'border-amber-500/20 bg-amber-500/5',
titleClassName: 'text-amber-700 dark:text-amber-300'
},
missing: { card: 'border-border/50 bg-muted/20', titleClassName: 'text-foreground' }
}
function getYamlStateCopy(yamlState: string): { heading: string; description: string } {
switch (yamlState) {
case 'loaded':
return {
heading: translate(
'auto.components.settings.RepositoryHooksSection.56f9a4a1d0',
'Using `orca.yaml`'
),
description: translate(
'auto.components.settings.RepositoryHooksSection.ca424ff135',
'Shared hook and issue-automation defaults are defined in the repo and available to everyone who uses it.'
)
}
case 'update-available':
return {
heading: translate(
'auto.components.settings.RepositoryHooksSection.623e0c9f31',
'`orca.yaml` could not be parsed'
),
description: translate(
'auto.components.settings.RepositoryHooksSection.aba825233f',
'The file contains configuration keys that this version of Orca does not recognize. You may need to update Orca, or check the file for typos.'
)
}
case 'invalid':
return {
heading: translate(
'auto.components.settings.RepositoryHooksSection.623e0c9f31',
'`orca.yaml` could not be parsed'
),
description: translate(
'auto.components.settings.RepositoryHooksSection.0cc712b823',
'The core configuration file exists in the repo root, but Orca could not parse the supported hook definitions yet.'
)
}
default:
return {
heading: translate(
'auto.components.settings.RepositoryHooksSection.5a67e4793d',
'No `orca.yaml` detected'
),
description: translate(
'auto.components.settings.RepositoryHooksSection.b20c5df6ca',
'Add an `orca.yaml` file to enable shared setup, archive, or issue-automation defaults for this repo. Example template:'
)
}
}
}
function getParseErrorFixes(): string[] {
return [
translate(
'auto.components.settings.RepositoryHooksSection.07ba35bc68',
'Check the indentation under `scripts:`. Hook keys should use two spaces, and command lines should use four.'
),
translate(
'auto.components.settings.RepositoryHooksSection.787ca433ef',
'Define only the supported keys: `scripts`, `setup`, `archive`, and `issueCommand`.'
),
translate(
'auto.components.settings.RepositoryHooksSection.ecc73d9125',
'Compare your file against the working template below and copy that shape if needed.'
)
]
}
function ExampleTemplateCard({
copiedTemplate,
onCopyTemplate
}: {
copiedTemplate: boolean
onCopyTemplate: () => void
}): React.JSX.Element {
return (
<div className="space-y-2">
<p className="text-[10px] tracking-[0.18em] text-muted-foreground">
{translate('auto.components.settings.RepositoryHooksSection.175daba180', 'Example')}{' '}
<code className="rounded bg-muted px-1 py-0.5">
{translate('auto.components.settings.RepositoryHooksSection.39da2ae12f', 'orca.yaml')}
</code>{' '}
{translate('auto.components.settings.RepositoryHooksSection.95a0411b3e', 'template')}
</p>
<div className="relative rounded-lg border border-border/50 bg-background/70">
<Button
type="button"
variant={copiedTemplate ? 'secondary' : 'ghost'}
size="sm"
className={`absolute right-2 top-2 z-10 h-6 px-2 text-[11px] ${copiedTemplate ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
onClick={onCopyTemplate}
>
{copiedTemplate
? translate('auto.components.settings.RepositoryHooksSection.3149964b66', 'Copied')
: translate('auto.components.settings.RepositoryHooksSection.da37d6f10e', 'Copy')}
</Button>
<pre className="overflow-x-auto whitespace-pre-wrap break-words p-3 pr-16 font-mono text-[11px] leading-5 text-muted-foreground">
{EXAMPLE_TEMPLATE}
</pre>
</div>
</div>
)
}
export function RepositoryHooksYamlStatus({
yamlState,
yamlHooks,
copiedTemplate,
onCopyTemplate
}: {
yamlState: string
yamlHooks: OrcaHooks | null
copiedTemplate: boolean
onCopyTemplate: () => void
}): React.JSX.Element {
const copy = getYamlStateCopy(yamlState)
const parseErrorFixes = getParseErrorFixes()
return (
<div className={`space-y-3 rounded-xl border p-3 ${YAML_STATE_STYLES[yamlState].card}`}>
<div className="space-y-1">
<p className={`text-sm font-medium ${YAML_STATE_STYLES[yamlState].titleClassName}`}>
{copy.heading}
</p>
<p className="text-xs text-muted-foreground">{copy.description}</p>
</div>
{yamlState === 'loaded' ? (
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg border border-border/50 bg-muted/30 p-3 font-mono text-[11.5px] leading-5 text-foreground">
{renderYamlScriptPreview(yamlHooks)}
</pre>
) : yamlState === 'invalid' ? (
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-lg border border-amber-500/20 bg-background/60 p-3">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="space-y-2 text-xs text-muted-foreground">
<p>
{translate(
'auto.components.settings.RepositoryHooksSection.af49e2a19e',
'The file is present, but Orca could not find valid `scripts` or `issueCommand` definitions.'
)}
</p>
<ol className="space-y-1.5 pl-4 text-[11.5px]">
{parseErrorFixes.map((fix) => (
<li key={fix} className="list-decimal leading-5">
{fix}
</li>
))}
</ol>
</div>
</div>
<ExampleTemplateCard copiedTemplate={copiedTemplate} onCopyTemplate={onCopyTemplate} />
</div>
) : (
<ExampleTemplateCard copiedTemplate={copiedTemplate} onCopyTemplate={onCopyTemplate} />
)}
</div>
)
}
@@ -0,0 +1,82 @@
import { translate } from '@/i18n/i18n'
const ARTIFACT_URL_TEMPLATE_TOKEN = '{{artifact_url}}'
export function RepositoryIssueCommandSetting({
issueCommandDraft,
setIssueCommandDraft,
hasSharedIssueCommand,
issueCommandSaveError,
commitIssueCommand
}: {
issueCommandDraft: string
setIssueCommandDraft: (value: string) => void
hasSharedIssueCommand: boolean
issueCommandSaveError: string | null
commitIssueCommand: () => Promise<void>
}): React.JSX.Element {
return (
<div className="space-y-3 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm">
<div className="space-y-1">
<h5 className="text-sm font-semibold">
{translate(
'auto.components.settings.RepositoryHooksSection.13394103bd',
'Custom GitHub Issue Command'
)}
</h5>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RepositoryHooksSection.b997331366',
'Optional override. Use'
)}{' '}
<code className="rounded bg-muted px-1 py-0.5">
{translate(
'auto.components.settings.RepositoryHooksSection.c85c2c88a2',
'{{artifact_url}}',
{ artifact_url: ARTIFACT_URL_TEMPLATE_TOKEN }
)}
</code>{' '}
{translate(
'auto.components.settings.RepositoryHooksSection.70ad20f883',
'for the linked issue or PR URL.'
)}
</p>
</div>
<textarea
value={issueCommandDraft}
aria-label={translate(
'auto.components.settings.RepositoryHooksSection.13394103bd',
'Custom GitHub Issue Command'
)}
onChange={(event) => setIssueCommandDraft(event.target.value)}
onBlur={() => void commitIssueCommand()}
placeholder={translate(
'auto.components.settings.RepositoryHooksSection.4084720f47',
'Complete {{artifact_url}}',
{ artifact_url: ARTIFACT_URL_TEMPLATE_TOKEN }
)}
rows={4}
spellCheck={false}
className="w-full min-w-0 resize-y rounded-md border border-input bg-muted/20 px-3 py-2 font-mono text-xs shadow-xs transition-[color,box-shadow] outline-none placeholder:italic placeholder:text-muted-foreground/60 focus-visible:border-ring focus-visible:bg-background focus-visible:ring-[3px] focus-visible:ring-ring/40"
/>
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.RepositoryHooksSection.52aef29e69',
'Leave blank to use the repo default from'
)}{' '}
<code className="rounded bg-muted px-1 py-0.5">
{translate('auto.components.settings.RepositoryHooksSection.39da2ae12f', 'orca.yaml')}
</code>
{hasSharedIssueCommand
? '.'
: translate(
'auto.components.settings.RepositoryHooksSection.9b12f15b1e',
'when one exists.'
)}
</p>
{issueCommandSaveError ? (
<p className="text-xs text-destructive">{issueCommandSaveError}</p>
) : null}
</div>
)
}
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import {
buildAgentAvailabilitySettingsUpdate,
createAgentAvailabilityUpdateQueue
} from './agent-availability-settings'
describe('agent availability settings', () => {
it('normalizes duplicates and unknown ids before applying a request', () => {
expect(
buildAgentAvailabilitySettingsUpdate(
{
defaultTuiAgent: 'codex',
disabledTuiAgents: ['claude', 'claude', 'unknown-agent'] as never[]
},
'codex',
false
)
).toEqual({
disabledTuiAgents: ['claude', 'codex'],
defaultTuiAgent: null
})
})
it('continues serializing requests after a rejected write', async () => {
const settings: GlobalSettings = {
...getDefaultSettings('/tmp'),
defaultTuiAgent: null,
disabledTuiAgents: []
}
let latest = settings
const updateSettings = vi
.fn<(update: Partial<GlobalSettings>) => Promise<void>>()
.mockRejectedValueOnce(new Error('write failed'))
.mockImplementationOnce(async (update) => {
latest = { ...latest, ...update }
})
const enqueue = createAgentAvailabilityUpdateQueue()
await expect(
enqueue({
getSettings: () => latest,
fallbackSettings: settings,
updateSettings,
agentId: 'claude',
enabled: false
})
).rejects.toThrow('write failed')
await enqueue({
getSettings: () => latest,
fallbackSettings: settings,
updateSettings,
agentId: 'codex',
enabled: false
})
expect(updateSettings).toHaveBeenCalledTimes(2)
expect(updateSettings.mock.calls[1][0]).toMatchObject({ disabledTuiAgents: ['codex'] })
})
})
@@ -0,0 +1,47 @@
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection'
export type AgentAvailabilityUpdateQueueOptions = {
getSettings: () => GlobalSettings | null | undefined
fallbackSettings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void | Promise<void>
agentId: TuiAgent
enabled: boolean
}
export function buildAgentAvailabilitySettingsUpdate(
settings: Pick<GlobalSettings, 'defaultTuiAgent' | 'disabledTuiAgents'>,
id: TuiAgent,
enabled: boolean
): Pick<GlobalSettings, 'disabledTuiAgents'> & Partial<Pick<GlobalSettings, 'defaultTuiAgent'>> {
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<void> {
let pendingUpdate: Promise<unknown> = Promise.resolve()
return ({ getSettings, fallbackSettings, updateSettings, agentId, enabled }) => {
// Why: serialize full-array replacements so each write sees the reconciled store.
pendingUpdate = pendingUpdate
.catch(() => {})
.then(() =>
updateSettings(
buildAgentAvailabilitySettingsUpdate(getSettings() ?? fallbackSettings, agentId, enabled)
)
)
return pendingUpdate.then(() => undefined)
}
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import {
areHookSettingsDraftsEqual,
getHookSettingsDraft,
renderYamlScriptPreview
} from './repository-hook-settings-draft'
describe('repository hook settings draft', () => {
it('normalizes persisted settings without dropping either script', () => {
const draft = getHookSettingsDraft({
mode: 'override',
scripts: { setup: 'pnpm install', archive: '' }
})
expect(draft).toMatchObject({
mode: 'override',
setupRunPolicy: 'run-by-default',
setupAgentStartupPolicy: 'start-immediately',
scripts: { setup: 'pnpm install', archive: '' }
})
})
it('includes every persisted field in dirty-draft equality', () => {
const baseline = getHookSettingsDraft(undefined)
expect(areHookSettingsDraftsEqual(baseline, { ...baseline })).toBe(true)
expect(
areHookSettingsDraftsEqual(baseline, {
...baseline,
setupAgentStartupPolicy: 'wait-for-setup'
})
).toBe(false)
expect(
areHookSettingsDraftsEqual(baseline, {
...baseline,
scripts: { ...baseline.scripts, archive: 'cleanup' }
})
).toBe(false)
})
it('renders the exact shared YAML projection', () => {
expect(
renderYamlScriptPreview({
scripts: { setup: 'pnpm install\npnpm build', archive: 'pnpm clean' },
issueCommand: 'Complete {{artifact_url}}'
})
).toBe(
'scripts:\n setup: |\n pnpm install\n pnpm build\n archive: |\n pnpm clean\nissueCommand: |\n Complete {{artifact_url}}'
)
})
})
@@ -0,0 +1,124 @@
import type {
HookCommandSourcePolicy,
OrcaHooks,
RepoHookSettings
} from '../../../../shared/orca-yaml-hook-types'
import type { Repo } from '../../../../shared/repo-types'
import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants'
import { translate } from '@/i18n/i18n'
export type LocalHookName = 'setup' | 'archive'
export type LocalHookField = {
name: LocalHookName
label: string
description: string
placeholder: string
}
export type HookSettingsPolicyDraft = Partial<
Pick<RepoHookSettings, 'setupRunPolicy' | 'setupAgentStartupPolicy' | 'commandSourcePolicy'>
>
export function getLocalHookFields(): readonly [LocalHookField, LocalHookField] {
return [
{
name: 'setup',
label: translate(
'auto.components.settings.RepositoryHooksSection.52b31baf02',
'Setup Script'
),
description: translate(
'auto.components.settings.RepositoryHooksSection.f0710e1c83',
'Runs after a new worktree is created; install deps, copy env files, run migrations.'
),
placeholder: translate(
'auto.components.settings.RepositoryHooksSection.a3fc966677',
'# e.g. pnpm install cp "$ORCA_ROOT_PATH/.env" "$ORCA_WORKTREE_PATH/.env"'
)
},
{
name: 'archive',
label: translate(
'auto.components.settings.RepositoryHooksSection.9a100323ff',
'Archive Script'
),
description: translate(
'auto.components.settings.RepositoryHooksSection.6f90ebe3fd',
'Runs before a worktree is archived or removed.'
),
placeholder: translate(
'auto.components.settings.RepositoryHooksSection.9b821fa19d',
'# e.g. echo "Cleaning up $ORCA_WORKSPACE_NAME"'
)
}
]
}
export function getHookSettingsDraft(hookSettings: Repo['hookSettings']): RepoHookSettings {
return {
...DEFAULT_REPO_HOOK_SETTINGS,
...hookSettings,
scripts: {
...DEFAULT_REPO_HOOK_SETTINGS.scripts,
...hookSettings?.scripts
}
}
}
export function areHookSettingsDraftsEqual(a: RepoHookSettings, b: RepoHookSettings): boolean {
return (
a.mode === b.mode &&
a.setupRunPolicy === b.setupRunPolicy &&
a.setupAgentStartupPolicy === b.setupAgentStartupPolicy &&
a.commandSourcePolicy === b.commandSourcePolicy &&
a.scripts.setup === b.scripts.setup &&
a.scripts.archive === b.scripts.archive
)
}
export type LocalCommandSourcePolicyNotice =
| { kind: 'checking' }
| { kind: 'action'; policy: 'local-only' | 'run-both'; label: string }
export function getLocalCommandSourcePolicyNotice({
hooksInspectionReady,
currentPolicy,
setupScript,
archiveScript,
hasSharedScript
}: {
hooksInspectionReady: boolean
currentPolicy: HookCommandSourcePolicy
setupScript: string | undefined
archiveScript: string | undefined
hasSharedScript: boolean
}): LocalCommandSourcePolicyNotice | null {
if ((!setupScript?.trim() && !archiveScript?.trim()) || currentPolicy !== 'shared-only') {
return null
}
if (!hooksInspectionReady) {
return { kind: 'checking' }
}
return hasSharedScript
? {
kind: 'action',
policy: 'run-both',
label: translate('auto.components.settings.RepositoryHooksSection.8d6c56bff8', 'Run both')
}
: {
kind: 'action',
policy: 'local-only',
label: translate(
'auto.components.settings.RepositoryHooksSection.8bfe65fc60',
'Use local commands'
)
}
}
export function renderYamlScriptPreview(hooks: OrcaHooks | null): string {
const formatScript = (key: string, command?: string): string =>
command ? `\n ${key}: |\n${command.replace(/^/gm, ' ')}` : ''
const issueCommand = hooks?.issueCommand
? `\nissueCommand: |\n${hooks.issueCommand.replace(/^/gm, ' ')}`
: ''
return `scripts:${formatScript('setup', hooks?.scripts.setup)}${formatScript('archive', hooks?.scripts.archive)}${issueCommand}`
}
@@ -0,0 +1,138 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
import { useRepositoryHookSettingsDraft } from './use-repository-hook-settings-draft'
type DraftController = ReturnType<typeof useRepositoryHookSettingsDraft>
let latest: DraftController | null = null
let root: Root | null = null
let container: HTMLDivElement | null = null
const baseRepo: Repo = {
id: 'repo-1',
kind: 'git',
path: '/repo',
displayName: 'Repo',
badgeColor: 'blue',
addedAt: 1,
gitUsername: '',
hookSettings: {
mode: 'auto',
setupRunPolicy: 'run-by-default',
setupAgentStartupPolicy: 'start-immediately',
commandSourcePolicy: 'shared-only',
scripts: { setup: '', archive: '' }
}
}
function Harness({
repo,
identity,
persist
}: {
repo: Repo
identity: string
persist: (settings: NonNullable<Repo['hookSettings']>) => void
}): null {
latest = useRepositoryHookSettingsDraft({
repo,
repoHostIdentity: identity,
onUpdateHookSettings: persist
})
return null
}
function renderHarness(
repo: Repo,
identity: string,
persist: (settings: NonNullable<Repo['hookSettings']>) => void
): void {
if (!root) {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
}
act(() => root?.render(<Harness repo={repo} identity={identity} persist={persist} />))
}
beforeEach(() => vi.useFakeTimers())
afterEach(() => {
act(() => root?.unmount())
container?.remove()
root = null
container = null
latest = null
vi.useRealTimers()
})
describe('repository hook draft persistence', () => {
it('coalesces typing and makes blur cancel the pending duplicate save', () => {
const persist = vi.fn()
renderHarness(baseRepo, 'local\0repo-1', persist)
act(() => {
latest?.updateScriptDraft('setup', 'pnpm install')
latest?.updateScriptDraft('setup', 'pnpm install\npnpm build')
latest?.commitScriptDraft()
})
expect(persist).toHaveBeenCalledTimes(1)
expect(persist.mock.calls[0][0].scripts.setup).toBe('pnpm install\npnpm build')
act(() => vi.advanceTimersByTime(700))
expect(persist).toHaveBeenCalledTimes(1)
})
it('persists a policy change together with the latest dirty script', () => {
const persist = vi.fn()
renderHarness(baseRepo, 'local\0repo-1', persist)
act(() => {
latest?.updateScriptDraft('archive', 'pnpm clean')
latest?.updateHookSettingsPolicyDraft({ commandSourcePolicy: 'run-both' })
})
expect(persist).toHaveBeenCalledTimes(1)
expect(persist.mock.calls[0][0]).toMatchObject({
commandSourcePolicy: 'run-both',
scripts: { setup: '', archive: 'pnpm clean' }
})
act(() => vi.advanceTimersByTime(700))
expect(persist).toHaveBeenCalledTimes(1)
})
it('flushes a host switch through the previous owner before resetting the draft', () => {
const localPersist = vi.fn()
const remotePersist = vi.fn()
renderHarness(baseRepo, 'local\0repo-1', localPersist)
act(() => latest?.updateScriptDraft('setup', 'local draft'))
renderHarness(
{
...baseRepo,
hookSettings: { ...baseRepo.hookSettings!, scripts: { setup: 'remote', archive: '' } }
},
'runtime:server-1\0repo-1',
remotePersist
)
expect(localPersist).toHaveBeenCalledTimes(1)
expect(localPersist.mock.calls[0][0].scripts.setup).toBe('local draft')
expect(remotePersist).not.toHaveBeenCalled()
expect(latest?.hookSettingsDraft.scripts.setup).toBe('remote')
})
it('does not let a same-owner prop echo overwrite a dirty draft', () => {
const persist = vi.fn()
renderHarness(baseRepo, 'local\0repo-1', persist)
act(() => latest?.updateScriptDraft('setup', 'dirty'))
renderHarness(
{
...baseRepo,
hookSettings: { ...baseRepo.hookSettings!, scripts: { setup: 'stale echo', archive: '' } }
},
'local\0repo-1',
persist
)
expect(latest?.hookSettingsDraft.scripts.setup).toBe('dirty')
})
})
@@ -0,0 +1,133 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { RepoHookSettings } from '../../../../shared/orca-yaml-hook-types'
import type { Repo } from '../../../../shared/repo-types'
import {
areHookSettingsDraftsEqual,
getHookSettingsDraft,
type HookSettingsPolicyDraft,
type LocalHookName
} from './repository-hook-settings-draft'
export function useRepositoryHookSettingsDraft({
repo,
repoHostIdentity,
onUpdateHookSettings
}: {
repo: Repo
repoHostIdentity: string
onUpdateHookSettings: (settings: RepoHookSettings) => void
}): {
hookSettingsDraft: RepoHookSettings
updateScriptDraft: (hookName: LocalHookName, nextScript: string) => void
commitScriptDraft: () => void
flushScriptDraftOnUnmount: (node: HTMLElement | null) => void
updateHookSettingsPolicyDraft: (updates: HookSettingsPolicyDraft) => void
} {
const [hookSettingsDraft, setHookSettingsDraft] = useState(() =>
getHookSettingsDraft(repo.hookSettings)
)
const hookSettingsDraftRef = useRef(hookSettingsDraft)
const localCommandsRepoIdentityRef = useRef(repoHostIdentity)
const localCommandsDraftDirtyRef = useRef(false)
const localCommandsAutosaveTimerRef = useRef<number | null>(null)
const persistRef = useRef(onUpdateHookSettings)
const localCommandsPersistForRepoRef = useRef(onUpdateHookSettings)
useEffect(() => {
persistRef.current = onUpdateHookSettings
}, [onUpdateHookSettings])
const syncHookSettingsDraft = useCallback((next: RepoHookSettings) => {
if (!areHookSettingsDraftsEqual(hookSettingsDraftRef.current, next)) {
hookSettingsDraftRef.current = next
setHookSettingsDraft(next)
}
}, [])
const clearLocalCommandsAutosaveTimer = useCallback(() => {
if (localCommandsAutosaveTimerRef.current !== null) {
window.clearTimeout(localCommandsAutosaveTimerRef.current)
localCommandsAutosaveTimerRef.current = null
}
}, [])
const flushScriptDraft = useCallback(
(persistHookSettings?: (settings: RepoHookSettings) => void) => {
clearLocalCommandsAutosaveTimer()
if (!localCommandsDraftDirtyRef.current) {
return
}
localCommandsDraftDirtyRef.current = false
;(persistHookSettings ?? persistRef.current)(hookSettingsDraftRef.current)
},
[clearLocalCommandsAutosaveTimer]
)
const queueScriptDraftPersist = useCallback(() => {
localCommandsDraftDirtyRef.current = true
clearLocalCommandsAutosaveTimer()
// Why: repo persistence may be an SSH RPC; coalesce typing bursts.
localCommandsAutosaveTimerRef.current = window.setTimeout(flushScriptDraft, 700)
}, [clearLocalCommandsAutosaveTimer, flushScriptDraft])
const updateScriptDraft = useCallback(
(hookName: LocalHookName, nextScript: string) => {
const current = hookSettingsDraftRef.current
const next: RepoHookSettings = {
...current,
scripts: { ...current.scripts, [hookName]: nextScript }
}
hookSettingsDraftRef.current = next
setHookSettingsDraft(next)
queueScriptDraftPersist()
},
[queueScriptDraftPersist]
)
const commitScriptDraft = useCallback(() => flushScriptDraft(), [flushScriptDraft])
const flushScriptDraftOnUnmount = useCallback(
(node: HTMLElement | null): void => {
if (node === null) {
flushScriptDraft()
}
},
[flushScriptDraft]
)
const updateHookSettingsPolicyDraft = useCallback((updates: HookSettingsPolicyDraft) => {
const next = { ...hookSettingsDraftRef.current, ...updates }
hookSettingsDraftRef.current = next
setHookSettingsDraft(next)
localCommandsDraftDirtyRef.current = false
persistRef.current(next)
}, [])
useEffect(() => {
const next = getHookSettingsDraft(repo.hookSettings)
if (localCommandsRepoIdentityRef.current === repoHostIdentity) {
localCommandsPersistForRepoRef.current = onUpdateHookSettings
if (!localCommandsDraftDirtyRef.current) {
syncHookSettingsDraft(next)
}
return
}
flushScriptDraft(localCommandsPersistForRepoRef.current)
localCommandsRepoIdentityRef.current = repoHostIdentity
localCommandsPersistForRepoRef.current = onUpdateHookSettings
hookSettingsDraftRef.current = next
setHookSettingsDraft(next)
}, [
flushScriptDraft,
onUpdateHookSettings,
repo.hookSettings,
repoHostIdentity,
syncHookSettingsDraft
])
return {
hookSettingsDraft,
updateScriptDraft,
commitScriptDraft,
flushScriptDraftOnUnmount,
updateHookSettingsPolicyDraft
}
}
@@ -0,0 +1,98 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { readRuntimeIssueCommand, writeRuntimeIssueCommand } from '@/runtime/runtime-hooks-client'
import type { ExecutionHostId } from '../../../../shared/execution-host'
type HookRuntimeSettings = { activeRuntimeEnvironmentId: string | null }
export function useRepositoryIssueCommand({
hookRuntimeSettings,
repoId,
repoHostIdentity,
selectedHostId
}: {
hookRuntimeSettings: HookRuntimeSettings
repoId: string
repoHostIdentity: string
selectedHostId: ExecutionHostId
}): {
issueCommandDraft: string
setIssueCommandDraft: (value: string) => void
hasSharedIssueCommand: boolean
issueCommandSaveError: string | null
commitIssueCommand: () => Promise<void>
} {
const [issueCommandDraft, setIssueCommandDraft] = useState('')
const [hasSharedIssueCommand, setHasSharedIssueCommand] = useState(false)
const [issueCommandSaveError, setIssueCommandSaveError] = useState<string | null>(null)
const issueCommandDraftRef = useRef(issueCommandDraft)
const lastCommittedIssueCommandRef = useRef('')
const updateIssueCommandDraft = useCallback((value: string) => {
issueCommandDraftRef.current = value
setIssueCommandDraft(value)
}, [])
useEffect(() => {
let cancelled = false
updateIssueCommandDraft('')
setHasSharedIssueCommand(false)
setIssueCommandSaveError(null)
void readRuntimeIssueCommand(hookRuntimeSettings, repoId, selectedHostId)
.then((result) => {
if (cancelled) {
return
}
const localContent = result.localContent ?? ''
updateIssueCommandDraft(localContent)
setHasSharedIssueCommand(Boolean(result.sharedContent))
lastCommittedIssueCommandRef.current = localContent
})
.catch(() => {
if (cancelled) {
return
}
updateIssueCommandDraft('')
setHasSharedIssueCommand(false)
lastCommittedIssueCommandRef.current = ''
})
return () => {
cancelled = true
const draft = issueCommandDraftRef.current.trim()
if (draft !== lastCommittedIssueCommandRef.current) {
void writeRuntimeIssueCommand(hookRuntimeSettings, repoId, draft, selectedHostId).catch(
(error) => {
console.error(
'[RepositoryHooksSection] Failed to save issue command on unmount:',
error
)
}
)
}
}
}, [hookRuntimeSettings, repoHostIdentity, repoId, selectedHostId, updateIssueCommandDraft])
const commitIssueCommand = useCallback(async (): Promise<void> => {
const trimmed = issueCommandDraft.trim()
updateIssueCommandDraft(trimmed)
try {
await writeRuntimeIssueCommand(hookRuntimeSettings, repoId, trimmed, selectedHostId)
lastCommittedIssueCommandRef.current = trimmed
setIssueCommandSaveError(null)
} catch (error) {
console.error('[RepositoryHooksSection] Failed to write issue command:', error)
const message =
error instanceof Error ? error.message : 'Failed to save GitHub issue command.'
setIssueCommandSaveError(message)
toast.error(message)
}
}, [hookRuntimeSettings, issueCommandDraft, repoId, selectedHostId, updateIssueCommandDraft])
return {
issueCommandDraft,
setIssueCommandDraft: updateIssueCommandDraft,
hasSharedIssueCommand,
issueCommandSaveError,
commitIssueCommand
}
}
@@ -34,17 +34,17 @@ const CASES: GuardCase[] = [
label: 'Found a setup command in source'
},
{
file: 'components/settings/RepositoryHooksSection.tsx',
file: 'components/settings/RepositoryHooksYamlStatus.tsx',
afterFallback: 'Example',
label: 'Example orca.yaml template'
},
{
file: 'components/settings/RepositoryHooksSection.tsx',
file: 'components/settings/RepositoryHookScriptSetting.tsx',
afterFallback: 'Edit',
label: 'Edit orca.yaml'
},
{
file: 'components/settings/RepositoryHooksSection.tsx',
file: 'components/settings/RepositoryHookPolicySettings.tsx',
afterFallback: 'When both',
label: 'When both orca.yaml'
},