mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
'Couldn’t 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'
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user