diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index d0b0f0498c9..4d20637bf7a 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -41,6 +41,8 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { useAllWorktrees, useRepoMap } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { Button } from '@/components/ui/button' import { ButtonGroup } from '@/components/ui/button-group' import { Input } from '@/components/ui/input' @@ -2467,8 +2469,10 @@ export default function TaskPage(): React.JSX.Element { const workItemsInvalidationNonce = useAppStore((s) => s.workItemsInvalidationNonce) const linearStatus = useAppStore((s) => s.linearStatus) const linearStatusChecked = useAppStore((s) => s.linearStatusChecked) + const linearStatusContextKey = useAppStore((s) => s.linearStatusContextKey) const preflightStatus = useAppStore((s) => s.preflightStatus) const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) + const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey) const selectLinearWorkspace = useAppStore((s) => s.selectLinearWorkspace) const searchLinearIssues = useAppStore((s) => s.searchLinearIssues) const listLinearIssues = useAppStore((s) => s.listLinearIssues) @@ -2487,13 +2491,27 @@ export default function TaskPage(): React.JSX.Element { const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + const expectedPreflightContextKey = useAppStore((s) => + localPreflightContextKey(getLocalPreflightContext(s)) + ) const jiraStatus = useAppStore((s) => s.jiraStatus) const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked) + const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey) const connectJira = useAppStore((s) => s.connectJira) const selectJiraSite = useAppStore((s) => s.selectJiraSite) const searchJiraIssues = useAppStore((s) => s.searchJiraIssues) const listJiraIssues = useAppStore((s) => s.listJiraIssues) const checkJiraConnection = useAppStore((s) => s.checkJiraConnection) + const providerRuntimeContextKey = getProviderRuntimeContextKey(settings) + const providerRuntimeContextKeyRef = useRef(providerRuntimeContextKey) + providerRuntimeContextKeyRef.current = providerRuntimeContextKey + const linearStatusCurrent = linearStatusContextKey === providerRuntimeContextKey + const jiraStatusCurrent = jiraStatusContextKey === providerRuntimeContextKey + const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey + const linearStatusReady = linearStatusCurrent && linearStatusChecked + const jiraStatusReady = jiraStatusCurrent && jiraStatusChecked + const linearConnected = linearStatusCurrent && linearStatus.connected + const jiraConnected = jiraStatusCurrent && jiraStatus.connected const submitShortcutLabel = getScreenSubmitShortcutLabel() const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) @@ -2588,15 +2606,16 @@ export default function TaskPage(): React.JSX.Element { restoreAvailableDefaultTaskProvider( preferredVisibleTaskProviders, { - gitlabInstalled: preflightStatus?.glab?.installed === true, - linearConnected: linearStatus.connected === true + gitlabInstalled: preflightStatusCurrent && preflightStatus?.glab?.installed === true, + linearConnected: linearConnected === true }, defaultTaskSource ), [ defaultTaskSource, - linearStatus.connected, + linearConnected, preferredVisibleTaskProviders, + preflightStatusCurrent, preflightStatus?.glab?.installed ] ) @@ -3412,7 +3431,7 @@ export default function TaskPage(): React.JSX.Element { linearContextResumeAttemptedRef.current || !taskResumeApplied || taskSource !== 'linear' || - !linearStatus.connected || + !linearConnected || !context ) { return @@ -3489,7 +3508,7 @@ export default function TaskPage(): React.JSX.Element { fetchLinearCustomView, fetchLinearProject, listLinearCustomViews, - linearStatus.connected, + linearConnected, setTaskResumeState, taskResumeApplied, taskResumeState?.linearContext, @@ -3506,7 +3525,7 @@ export default function TaskPage(): React.JSX.Element { if (!taskResumeApplied) { return } - if (taskSource !== 'linear' || !linearStatus.connected) { + if (taskSource !== 'linear' || !linearConnected) { setAvailableTeams([]) return } @@ -3533,7 +3552,7 @@ export default function TaskPage(): React.JSX.Element { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ taskSource, - linearStatus.connected, + linearConnected, selectedLinearWorkspaceId, linearTeamRefreshNonce, taskResumeApplied, @@ -3548,7 +3567,7 @@ export default function TaskPage(): React.JSX.Element { if (!taskResumeApplied) { return } - if (taskSource !== 'jira' || !jiraStatus.connected) { + if (taskSource !== 'jira' || !jiraConnected) { setAvailableJiraProjects([]) setJiraProjectsLoading(false) return @@ -3575,7 +3594,7 @@ export default function TaskPage(): React.JSX.Element { return () => { cancelled = true } - }, [settings, taskSource, jiraStatus.connected, selectedJiraSiteId, taskResumeApplied]) + }, [settings, taskSource, jiraConnected, selectedJiraSiteId, taskResumeApplied]) // Why: stable key for `selectedRepos` so the GitLab fetch effect below // doesn't re-run on every parent re-render just because the array @@ -4331,7 +4350,7 @@ export default function TaskPage(): React.JSX.Element { useEffect(() => { let cancelled = false - if (!newLinearIssueTargetTeam) { + if (!newLinearIssueOpen || !linearConnected || !newLinearIssueTargetTeam) { setNewLinearIssueProjects([]) setNewLinearIssueProjectsLoading(false) return @@ -4357,7 +4376,13 @@ export default function TaskPage(): React.JSX.Element { // populate the composer after a team/workspace switch. cancelled = true } - }, [newLinearIssueTargetTeam, settings, selectedLinearWorkspaceId]) + }, [ + linearConnected, + newLinearIssueOpen, + newLinearIssueTargetTeam, + settings, + selectedLinearWorkspaceId + ]) useEffect(() => { // Why: the selected team can change indirectly when the available Linear @@ -4377,17 +4402,17 @@ export default function TaskPage(): React.JSX.Element { }, [newLinearIssueTargetTeam?.id, newLinearIssueTargetTeam?.workspaceId, selectedLinearProject]) const newLinearStates = useTeamStates( - newLinearIssueTargetTeam?.id || null, + linearConnected ? newLinearIssueTargetTeam?.id || null : null, settings, newLinearIssueTargetTeam?.workspaceId ) const newLinearMembers = useTeamMembers( - newLinearIssueTargetTeam?.id || null, + linearConnected ? newLinearIssueTargetTeam?.id || null : null, settings, newLinearIssueTargetTeam?.workspaceId ) const newLinearLabels = useTeamLabels( - newLinearIssueTargetTeam?.id || null, + linearConnected ? newLinearIssueTargetTeam?.id || null : null, settings, newLinearIssueTargetTeam?.workspaceId ) @@ -4457,6 +4482,45 @@ export default function TaskPage(): React.JSX.Element { const [jiraConnectState, setJiraConnectState] = useState<'idle' | 'connecting' | 'error'>('idle') const [jiraConnectError, setJiraConnectError] = useState(null) const includeJiraSiteNameInProjectLabel = selectedJiraSiteId === 'all' + const previousProviderRuntimeContextKeyRef = useRef(providerRuntimeContextKey) + + useEffect(() => { + if (previousProviderRuntimeContextKeyRef.current === providerRuntimeContextKey) { + return + } + previousProviderRuntimeContextKeyRef.current = providerRuntimeContextKey + if (newLinearIssueOpen) { + setNewLinearIssueOpen(false) + setNewLinearIssueTitle('') + setNewLinearIssueBody('') + setNewLinearIssueTeamId(null) + setNewLinearIssueStateId(null) + setNewLinearIssueAssigneeId(null) + setNewLinearIssuePriority(0) + setNewLinearIssueProjectId(null) + setNewLinearIssueLabelIds([]) + setNewLinearIssueProjects([]) + setNewLinearIssueProjectsLoading(false) + setNewLinearIssueSubmitting(false) + } + if (newJiraIssueOpen) { + setNewJiraIssueOpen(false) + setNewJiraIssueTitle('') + setNewJiraIssueBody('') + setNewJiraIssueProjectId(null) + setNewJiraIssueProjectComboboxOpen(false) + setNewJiraIssueProjectQuery('') + setNewJiraIssueProjectCommandValue('') + setNewJiraIssueTypeId(null) + setAvailableJiraIssueTypes([]) + setJiraIssueTypesLoading(false) + setJiraCreateFields([]) + setJiraCreateFieldsLoading(false) + setJiraCreateFieldsError(null) + setNewJiraIssueCustomFieldValues({}) + setNewJiraIssueSubmitting(false) + } + }, [newJiraIssueOpen, newLinearIssueOpen, providerRuntimeContextKey]) const sortedAvailableJiraProjects = useMemo( () => @@ -4572,7 +4636,7 @@ export default function TaskPage(): React.JSX.Element { ) useEffect(() => { - if (!newJiraIssueOpen || !newJiraIssueTargetProject) { + if (!newJiraIssueOpen || !jiraConnected || !newJiraIssueTargetProject) { setAvailableJiraIssueTypes([]) setJiraIssueTypesLoading(false) return @@ -4607,10 +4671,15 @@ export default function TaskPage(): React.JSX.Element { return () => { cancelled = true } - }, [settings, newJiraIssueOpen, newJiraIssueTargetProject]) + }, [settings, jiraConnected, newJiraIssueOpen, newJiraIssueTargetProject]) useEffect(() => { - if (!newJiraIssueOpen || !newJiraIssueTargetProject || !newJiraIssueTargetType) { + if ( + !newJiraIssueOpen || + !jiraConnected || + !newJiraIssueTargetProject || + !newJiraIssueTargetType + ) { setJiraCreateFields([]) setJiraCreateFieldsLoading(false) setJiraCreateFieldsError(null) @@ -4648,7 +4717,7 @@ export default function TaskPage(): React.JSX.Element { // responses after the user switches either selector. cancelled = true } - }, [settings, newJiraIssueOpen, newJiraIssueTargetProject, newJiraIssueTargetType]) + }, [settings, jiraConnected, newJiraIssueOpen, newJiraIssueTargetProject, newJiraIssueTargetType]) // Why: defense-in-depth safety net applied to the current page's items. // The active tab scopes requests to issues or PRs, and this keeps stale @@ -5558,6 +5627,7 @@ export default function TaskPage(): React.JSX.Element { return } setNewLinearIssueSubmitting(true) + const submitProviderRuntimeContextKey = providerRuntimeContextKey try { const result = await linearCreateIssue(settings, { teamId: newLinearIssueTargetTeam.id, @@ -5570,6 +5640,9 @@ export default function TaskPage(): React.JSX.Element { projectId: newLinearIssueProjectId || null, labelIds: newLinearIssueLabelIds.length > 0 ? newLinearIssueLabelIds : undefined }) + if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) { + return + } if (!result.ok) { toast.error( result.error || @@ -5605,13 +5678,18 @@ export default function TaskPage(): React.JSX.Element { // sees exactly what was filed, mirroring the GitHub create-issue flow. void linearGetIssue(settings, result.id, newLinearIssueTargetTeam.workspaceId) .then((full) => { + if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) { + return + } if (full) { setSelectedLinearIssue(full, { allowOutsideList: true }) } }) .catch(() => {}) } finally { - setNewLinearIssueSubmitting(false) + if (submitProviderRuntimeContextKey === providerRuntimeContextKeyRef.current) { + setNewLinearIssueSubmitting(false) + } } }, [ newLinearIssueBody, @@ -5623,6 +5701,7 @@ export default function TaskPage(): React.JSX.Element { newLinearIssueAssigneeId, newLinearIssueProjectId, newLinearIssueLabelIds, + providerRuntimeContextKey, selectedLinearProject, setSelectedLinearIssue, settings @@ -5641,6 +5720,7 @@ export default function TaskPage(): React.JSX.Element { newJiraIssueCustomFieldValues ) setNewJiraIssueSubmitting(true) + const submitProviderRuntimeContextKey = providerRuntimeContextKey try { const result = await jiraCreateIssue(settings, { siteId: newJiraIssueTargetProject.siteId, @@ -5650,6 +5730,9 @@ export default function TaskPage(): React.JSX.Element { description: newJiraIssueBody || undefined, customFields }) + if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) { + return + } if (!result.ok) { toast.error( result.error || @@ -5678,6 +5761,9 @@ export default function TaskPage(): React.JSX.Element { void jiraGetIssue(settings, result.key, newJiraIssueTargetProject.siteId) .then((full) => { + if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) { + return + } if (full) { // Why: the list cache may still be fresh after create; insert the // new row locally before selecting it so the inspector stays open. @@ -5687,7 +5773,9 @@ export default function TaskPage(): React.JSX.Element { }) .catch(() => {}) } finally { - setNewJiraIssueSubmitting(false) + if (submitProviderRuntimeContextKey === providerRuntimeContextKeyRef.current) { + setNewJiraIssueSubmitting(false) + } } }, [ hasMissingJiraCreateField, @@ -5698,6 +5786,7 @@ export default function TaskPage(): React.JSX.Element { newJiraIssueTargetProject, newJiraIssueTargetType, newJiraIssueTitle, + providerRuntimeContextKey, settings, setSelectedJiraIssue, visibleJiraCreateFields @@ -5759,21 +5848,27 @@ export default function TaskPage(): React.JSX.Element { ]) useEffect(() => { - if (!preflightStatusChecked) { + if (!preflightStatusCurrent || !preflightStatusChecked) { void refreshPreflightStatus() } - if (!linearStatusChecked) { + if (!linearStatusReady) { void checkLinearConnection() } - if (!jiraStatusChecked) { + if (!jiraStatusReady) { void checkJiraConnection() } }, [ checkJiraConnection, checkLinearConnection, - jiraStatusChecked, - linearStatusChecked, + expectedPreflightContextKey, + jiraStatusContextKey, + jiraStatusReady, + linearStatusContextKey, + linearStatusReady, + providerRuntimeContextKey, + preflightStatusContextKey, preflightStatusChecked, + preflightStatusCurrent, refreshPreflightStatus ]) @@ -5826,7 +5921,7 @@ export default function TaskPage(): React.JSX.Element { if (linearMode !== 'issues') { return } - if (!linearStatus.connected) { + if (!linearConnected) { return } @@ -5940,7 +6035,7 @@ export default function TaskPage(): React.JSX.Element { }, [ taskSource, linearMode, - linearStatus.connected, + linearConnected, selectedLinearWorkspaceId, appliedLinearSearch, linearIssueLimit, @@ -5963,7 +6058,7 @@ export default function TaskPage(): React.JSX.Element { if (!taskResumeApplied || taskSource !== 'linear' || linearMode !== 'projects') { return } - if (!linearStatus.connected || selectedLinearProject) { + if (!linearConnected || selectedLinearProject) { return } let cancelled = false @@ -6000,7 +6095,7 @@ export default function TaskPage(): React.JSX.Element { taskResumeApplied, taskSource, linearMode, - linearStatus.connected, + linearConnected, selectedLinearWorkspaceId, selectedLinearProject, appliedLinearProjectSearch, @@ -6088,7 +6183,7 @@ export default function TaskPage(): React.JSX.Element { if (!taskResumeApplied || taskSource !== 'linear' || linearMode !== 'views') { return } - if (!linearStatus.connected || selectedLinearCustomView) { + if (!linearConnected || selectedLinearCustomView) { return } let cancelled = false @@ -6133,7 +6228,7 @@ export default function TaskPage(): React.JSX.Element { taskResumeApplied, taskSource, linearMode, - linearStatus.connected, + linearConnected, selectedLinearWorkspaceId, selectedLinearCustomView, linearRefreshNonce, @@ -6201,7 +6296,7 @@ export default function TaskPage(): React.JSX.Element { return } - if (!linearStatus.connected) { + if (!linearConnected) { clearSelectedLinearIssue() return } @@ -6227,7 +6322,7 @@ export default function TaskPage(): React.JSX.Element { }, [ clearSelectedLinearIssue, filteredLinearIssues, - linearStatus.connected, + linearConnected, selectedLinearIssueCanFloat, selectedLinearIssueId, taskResumeApplied, @@ -6262,7 +6357,7 @@ export default function TaskPage(): React.JSX.Element { if (taskSource !== 'jira') { return } - if (!jiraStatus.connected) { + if (!jiraConnected) { return } @@ -6298,7 +6393,7 @@ export default function TaskPage(): React.JSX.Element { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ taskSource, - jiraStatus.connected, + jiraConnected, selectedJiraSiteId, appliedJiraSearch, activeJiraPreset, @@ -6310,7 +6405,7 @@ export default function TaskPage(): React.JSX.Element { if (!taskResumeApplied || taskSource !== 'jira') { return } - if (!jiraStatus.connected || displayedJiraIssues.length === 0) { + if (!jiraConnected || displayedJiraIssues.length === 0) { if (selectedJiraIssueKey !== null) { setSelectedJiraIssueKey(null) } @@ -6328,7 +6423,7 @@ export default function TaskPage(): React.JSX.Element { } }, [ displayedJiraIssues, - jiraStatus.connected, + jiraConnected, selectedJiraIssueFallback, selectedJiraIssueKey, taskResumeApplied, @@ -6579,7 +6674,7 @@ export default function TaskPage(): React.JSX.Element { ) })} - {taskSource === 'linear' && linearStatus.connected ? ( + {taskSource === 'linear' && linearConnected ? (
) : null} - {taskSource === 'jira' && jiraStatus.connected ? ( + {taskSource === 'jira' && jiraConnected ? (
{jiraSites.length > 1 ? ( { + setSiteUrl(event.target.value) + clearErrorOnEdit() + }} + disabled={connectState === 'connecting'} + /> +
+
+ + { + setEmail(event.target.value) + clearErrorOnEdit() + }} + disabled={connectState === 'connecting'} + /> +
+
+ + { + setApiToken(event.target.value) + clearErrorOnEdit() + }} + disabled={connectState === 'connecting'} + aria-invalid={connectState === 'error'} + aria-describedby={connectState === 'error' ? errorId : undefined} + /> +
+ {connectState === 'error' && connectError ? ( +

+ {connectError} +

+ ) : null} +

+ {translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '} + + . +

+

+ + {credentialStorageCopy} +

+ + + + + + + + + ) +} diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index ac03b30e352..ec7af1d7e3f 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -41,6 +41,7 @@ import { } from '@/lib/github-links' import { lookupSmartGitHubSubmitItem } from '@/lib/smart-github-submit' import { parseGitLabIssueOrMRLink } from '@/lib/gitlab-links' +import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { cn } from '@/lib/utils' import { LinearIcon } from '@/components/icons/LinearIcon' import { JiraIcon } from '@/components/icons/JiraIcon' @@ -189,6 +190,8 @@ export default function SmartWorkspaceNameField({ listLinearIssues, preflightStatus, preflightStatusChecked, + preflightStatusContextKey, + expectedPreflightContextKey, refreshPreflightStatus, searchLinearIssues, settings @@ -203,6 +206,8 @@ export default function SmartWorkspaceNameField({ listLinearIssues: s.listLinearIssues, preflightStatus: s.preflightStatus, preflightStatusChecked: s.preflightStatusChecked, + preflightStatusContextKey: s.preflightStatusContextKey, + expectedPreflightContextKey: localPreflightContextKey(getLocalPreflightContext(s)), refreshPreflightStatus: s.refreshPreflightStatus, searchLinearIssues: s.searchLinearIssues, settings: s.settings @@ -239,13 +244,14 @@ export default function SmartWorkspaceNameField({ link: NonNullable> matchingRepo: RepoOption | null } | null>(null) + const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey const availableTaskProviders = useMemo( () => filterAvailableTaskProviders(['github', 'gitlab', 'linear'], { - gitlabInstalled: preflightStatus?.glab?.installed === true, + gitlabInstalled: preflightStatusCurrent && preflightStatus?.glab?.installed === true, linearConnected: linearStatus.connected === true }), - [linearStatus.connected, preflightStatus?.glab?.installed] + [linearStatus.connected, preflightStatus?.glab?.installed, preflightStatusCurrent] ) const gitlabAvailable = availableTaskProviders.includes('gitlab') const linearAvailable = availableTaskProviders.includes('linear') @@ -314,7 +320,7 @@ export default function SmartWorkspaceNameField({ if (disabled || textOnly) { return } - if (!preflightStatusChecked) { + if (!preflightStatusChecked || !preflightStatusCurrent) { void refreshPreflightStatus() } if (!linearStatusChecked) { @@ -325,6 +331,7 @@ export default function SmartWorkspaceNameField({ disabled, linearStatusChecked, preflightStatusChecked, + preflightStatusCurrent, refreshPreflightStatus, textOnly ]) diff --git a/src/renderer/src/components/settings/IntegrationsPane.tsx b/src/renderer/src/components/settings/IntegrationsPane.tsx index e99a671b301..c2414372ba0 100644 --- a/src/renderer/src/components/settings/IntegrationsPane.tsx +++ b/src/renderer/src/components/settings/IntegrationsPane.tsx @@ -1,891 +1,58 @@ -/* eslint-disable max-lines -- Why: this pane co-locates source-host and - Linear integration cards so the preflight-check + status-badge + - install/auth-prompt scaffolding lives in one place rather than fanning - out across per-integration files that would each repeat the same - pattern. Splitting buys nothing while the surface stays this narrow. */ -import { useEffect, useState } from 'react' import { - Github, - Gitlab, - GitPullRequestArrow, - ExternalLink, - LoaderCircle, - Terminal, - Unlink, - CheckCircle2, - AlertCircle -} from 'lucide-react' -import { useAppStore } from '../../store' -import { Button } from '../ui/button' -import { useMountedRef } from '@/hooks/useMountedRef' -import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' -import { - getPreflightIntegrationStatuses, - type PreflightRefreshProvider -} from './integrations-pane-status' -import { JiraIntegrationCard } from './jira-integration-card' + AzureDevOpsIntegrationCard, + BitbucketIntegrationCard, + GiteaIntegrationCard, + GitHubIntegrationCard, + GitLabIntegrationCard +} from './source-control-integration-cards' +import { JiraIntegrationCard, LinearIntegrationCard } from './task-tracker-integration-cards' +import { useIntegrationProviderStatusRefresh } from './use-integration-provider-status-refresh' import { translate } from '@/i18n/i18n' export { getIntegrationsPaneSearchEntries } from './integrations-search' -function LinearIcon({ className }: { className?: string }): React.JSX.Element { - return ( - - - - ) -} - export function IntegrationsPane(): React.JSX.Element { - const linearStatus = useAppStore((s) => s.linearStatus) - const preflightStatus = useAppStore((s) => s.preflightStatus) - const disconnectLinear = useAppStore((s) => s.disconnectLinear) - const disconnectLinearWorkspace = useAppStore((s) => s.disconnectLinearWorkspace) - const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) - const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) - const testLinearConnection = useAppStore((s) => s.testLinearConnection) - const linearWorkspaces = linearStatus.workspaces ?? [] - const mountedRef = useMountedRef() - - const [refreshingPreflightProviders, setRefreshingPreflightProviders] = useState< - Set - >(new Set()) - const [linearDialogOpen, setLinearDialogOpen] = useState(false) - const [linearTestingWorkspaceId, setLinearTestingWorkspaceId] = useState(null) - const [linearTestResultByWorkspace, setLinearTestResultByWorkspace] = useState< - Record - >({}) - - useEffect(() => { - void checkLinearConnection() - void refreshPreflightStatus() - }, [checkLinearConnection, refreshPreflightStatus]) - - const { - ghStatus, - glabStatus, - bitbucketStatus, - bitbucketAccount, - azureDevOpsStatus, - azureDevOpsAccount, - azureDevOpsBaseUrl, - giteaStatus, - giteaAccount, - giteaBaseUrl - } = getPreflightIntegrationStatuses(preflightStatus, refreshingPreflightProviders) - - const handleLinearDisconnect = async (workspaceId?: string): Promise => { - await (workspaceId ? disconnectLinearWorkspace(workspaceId) : disconnectLinear()) - if (!mountedRef.current) { - return - } - setLinearTestResultByWorkspace({}) - } - - // Why: explicit user-triggered verification. This is the *only* path in - // settings that decrypts the stored API key, so the macOS Keychain prompt - // (if the app signature has changed since the item was stored) only - // appears when the user clicks Test — not just for opening Settings. - const handleLinearTest = async (workspaceId: string): Promise => { - setLinearTestingWorkspaceId(workspaceId) - setLinearTestResultByWorkspace((prev) => { - const next = { ...prev } - delete next[workspaceId] - return next - }) - const result = await testLinearConnection(workspaceId) - if (!mountedRef.current) { - return - } - if (result.ok) { - setLinearTestResultByWorkspace((prev) => ({ - ...prev, - [workspaceId]: { state: 'ok' } - })) - } else { - setLinearTestResultByWorkspace((prev) => ({ - ...prev, - [workspaceId]: { state: 'error', error: result.error } - })) - } - setLinearTestingWorkspaceId(null) - } - - const refreshPreflightProvider = (provider: PreflightRefreshProvider): void => { - setRefreshingPreflightProviders((prev) => new Set(prev).add(provider)) - void refreshPreflightStatus({ force: true }).finally(() => { - if (!mountedRef.current) { - return - } - setRefreshingPreflightProviders((prev) => { - if (!prev.has(provider)) { - return prev - } - const next = new Set(prev) - next.delete(provider) - return next - }) - }) - } - - const handleRefreshGlab = (): void => refreshPreflightProvider('glab') - - const handleRefreshGh = (): void => refreshPreflightProvider('gh') - - const handleRefreshBitbucket = (): void => refreshPreflightProvider('bitbucket') - - const handleRefreshAzureDevOps = (): void => refreshPreflightProvider('azureDevOps') - - const handleRefreshGitea = (): void => refreshPreflightProvider('gitea') + useIntegrationProviderStatusRefresh() return ( -
- {/* GitHub */} -
-
- -
-

- {translate('auto.components.settings.IntegrationsPane.70c5f74f36', 'GitHub')} -

-

- {translate( - 'auto.components.settings.IntegrationsPane.de6a0d13ab', - 'Pull requests, issues, and checks via the' - )}{' '} - - {translate('auto.components.settings.IntegrationsPane.f36365ed45', 'gh')} - {' '} - {translate('auto.components.settings.IntegrationsPane.ea160a9978', 'CLI.')} -

-
- {ghStatus === 'checking' ? ( - - ) : ghStatus === 'connected' ? ( - - {translate('auto.components.settings.IntegrationsPane.6432f6522e', 'Connected')} - - ) : ( - - {ghStatus === 'not-installed' - ? translate('auto.components.settings.IntegrationsPane.f7eb5f0b24', 'Not installed') - : translate( - 'auto.components.settings.IntegrationsPane.15cf990798', - 'Not authenticated' - )} - - )} -
- - {ghStatus !== 'checking' && ghStatus !== 'connected' && ( -
- {ghStatus === 'not-installed' ? ( - <> -

- {translate( - 'auto.components.settings.IntegrationsPane.c0c8575e05', - 'Install the GitHub CLI to enable pull requests, issues, and checks.' - )} -

-
- - -
- - ) : ( - <> -

- {translate( - 'auto.components.settings.IntegrationsPane.09285e9fe6', - 'The GitHub CLI is installed but not authenticated. Run this command in a terminal:' - )} -

-
- - {translate( - 'auto.components.settings.IntegrationsPane.51000487c4', - 'gh auth login' - )} -
-
- - -
- +
+
+
+

+ {translate('auto.components.settings.IntegrationsPane.298c65ecac', 'Review providers')} +

+

+ {translate( + 'auto.components.settings.IntegrationsPane.1683acbac4', + 'Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.' )} -

- )} -
- - {/* GitLab */} -
-
- -
-

- {translate('auto.components.settings.IntegrationsPane.513abfe47d', 'GitLab')} -

-

- {translate( - 'auto.components.settings.IntegrationsPane.027440e1cb', - 'Merge requests, issues, todos, and pipelines via the' - )}{' '} - - {translate('auto.components.settings.IntegrationsPane.a3326f6f1b', 'glab')} - {' '} - {translate('auto.components.settings.IntegrationsPane.ea160a9978', 'CLI.')} -

-
- {glabStatus === 'checking' ? ( - - ) : glabStatus === 'connected' ? ( - - {translate('auto.components.settings.IntegrationsPane.6432f6522e', 'Connected')} - - ) : ( - - {glabStatus === 'not-installed' - ? translate('auto.components.settings.IntegrationsPane.f7eb5f0b24', 'Not installed') - : translate( - 'auto.components.settings.IntegrationsPane.15cf990798', - 'Not authenticated' - )} - - )} +

+
+ + + + + +
+ - {glabStatus !== 'checking' && glabStatus !== 'connected' && ( -
- {glabStatus === 'not-installed' ? ( - <> -

- {translate( - 'auto.components.settings.IntegrationsPane.35a3379372', - 'Install the GitLab CLI to enable merge requests, issues, and pipelines.' - )} -

-
- - -
- - ) : ( - <> -

- {translate( - 'auto.components.settings.IntegrationsPane.05e5245af7', - 'The GitLab CLI is installed but not authenticated. Run this command in a terminal:' - )} -

-
- - {translate( - 'auto.components.settings.IntegrationsPane.e74de656ce', - 'glab auth login' - )} -
-
- - -
- +
+
+

+ {translate('auto.components.settings.IntegrationsPane.70e885705b', 'Task providers')} +

+

+ {translate( + 'auto.components.settings.IntegrationsPane.3ba07f933b', + 'Connect issue trackers Orca can use to browse tasks and start workspaces with linked context.' )} -

- )} -
- - {/* Bitbucket */} -
-
- -
-

- {translate('auto.components.settings.IntegrationsPane.8489c0aa49', 'Bitbucket')} -

-

- {bitbucketStatus === 'connected' - ? bitbucketAccount - ? translate( - 'auto.components.settings.IntegrationsPane.277fc23929', - '{{value0}} · Pull requests and build statuses', - { value0: bitbucketAccount } - ) - : translate( - 'auto.components.settings.IntegrationsPane.9707523939', - 'Pull requests and build statuses' - ) - : translate( - 'auto.components.settings.IntegrationsPane.0879860c58', - 'Pull requests and build statuses via Bitbucket Cloud API tokens.' - )} -

-
- {bitbucketStatus === 'checking' ? ( - - ) : bitbucketStatus === 'connected' ? ( - - {translate('auto.components.settings.IntegrationsPane.6432f6522e', 'Connected')} - - ) : ( - - {bitbucketStatus === 'not-configured' - ? translate( - 'auto.components.settings.IntegrationsPane.f92fbf11aa', - 'Not configured' - ) - : translate('auto.components.settings.IntegrationsPane.45bf5e6e4b', 'Auth failed')} - - )} +

- - {bitbucketStatus !== 'checking' && bitbucketStatus !== 'connected' && ( -
- {bitbucketStatus === 'not-configured' ? ( - <> -

- {translate('auto.components.settings.IntegrationsPane.4ee74d1470', 'Set')} - - {translate( - 'auto.components.settings.IntegrationsPane.b8a7efb3f6', - 'ORCA_BITBUCKET_EMAIL' - )} - {' '} - {translate('auto.components.settings.IntegrationsPane.a6c2816115', 'and')}{' '} - - {translate( - 'auto.components.settings.IntegrationsPane.44cde4aa01', - 'ORCA_BITBUCKET_API_TOKEN' - )} - - {translate('auto.components.settings.IntegrationsPane.ce3c58cd63', ', or set')}{' '} - - {translate( - 'auto.components.settings.IntegrationsPane.6e0ff3403e', - 'ORCA_BITBUCKET_ACCESS_TOKEN' - )} - - . -

-
- - -
- - ) : ( - <> -

- {translate( - 'auto.components.settings.IntegrationsPane.3c3cf05c63', - 'Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.' - )} -

-
- - -
- - )} -
- )} -
- - {/* Azure DevOps */} -
-
- -
-

- {translate('auto.components.settings.IntegrationsPane.5efce6953d', 'Azure DevOps')} -

-

- {azureDevOpsStatus === 'configured' - ? azureDevOpsAccount - ? translate( - 'auto.components.settings.IntegrationsPane.277fc23929', - '{{value0}} · Pull requests and build statuses', - { value0: azureDevOpsAccount } - ) - : azureDevOpsBaseUrl - ? translate( - 'auto.components.settings.IntegrationsPane.277fc23929', - '{{value0}} · Pull requests and build statuses', - { value0: azureDevOpsBaseUrl } - ) - : translate( - 'auto.components.settings.IntegrationsPane.e3d5a24979', - 'Pull requests and build statuses for detected Azure Repos' - ) - : translate( - 'auto.components.settings.IntegrationsPane.6791d7af95', - 'Pull requests and build statuses via Azure DevOps REST API tokens.' - )} -

-
- {azureDevOpsStatus === 'checking' ? ( - - ) : azureDevOpsStatus === 'configured' ? ( - - {azureDevOpsAccount - ? translate('auto.components.settings.IntegrationsPane.6432f6522e', 'Connected') - : translate('auto.components.settings.IntegrationsPane.e7a961e1c5', 'Configured')} - - ) : ( - - {azureDevOpsStatus === 'not-configured' - ? translate( - 'auto.components.settings.IntegrationsPane.f92fbf11aa', - 'Not configured' - ) - : translate('auto.components.settings.IntegrationsPane.45bf5e6e4b', 'Auth failed')} - - )} +
+ +
- - {azureDevOpsStatus !== 'checking' && azureDevOpsStatus !== 'configured' && ( -
- {azureDevOpsStatus === 'not-configured' ? ( - <> -

- {translate('auto.components.settings.IntegrationsPane.4ee74d1470', 'Set')} - - {translate( - 'auto.components.settings.IntegrationsPane.5ee6ef6405', - 'ORCA_AZURE_DEVOPS_TOKEN' - )} - - {translate('auto.components.settings.IntegrationsPane.ce3c58cd63', ', or set')}{' '} - - {translate( - 'auto.components.settings.IntegrationsPane.8f960935c1', - 'ORCA_AZURE_DEVOPS_ACCESS_TOKEN' - )} - - {translate('auto.components.settings.IntegrationsPane.67a9f26a80', '. Set')}{' '} - - {translate( - 'auto.components.settings.IntegrationsPane.ae6b7f5f40', - 'ORCA_AZURE_DEVOPS_API_BASE_URL' - )} - {' '} - {translate( - 'auto.components.settings.IntegrationsPane.6f317f5132', - 'only when Orca cannot derive the API base URL from the git remote.' - )} -

-
- - -
- - ) : ( - <> -

- {translate( - 'auto.components.settings.IntegrationsPane.953b7bf6f7', - 'Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.' - )} -

-
- - -
- - )} -
- )} -
- - {/* Gitea */} -
-
- -
-

- {translate('auto.components.settings.IntegrationsPane.4ab9b96925', 'Gitea')} -

-

- {giteaStatus === 'configured' - ? giteaAccount - ? translate( - 'auto.components.settings.IntegrationsPane.1fac9b4910', - '{{value0}} · Pull requests and commit statuses', - { value0: giteaAccount } - ) - : giteaBaseUrl - ? translate( - 'auto.components.settings.IntegrationsPane.1fac9b4910', - '{{value0}} · Pull requests and commit statuses', - { value0: giteaBaseUrl } - ) - : translate( - 'auto.components.settings.IntegrationsPane.6355fe585e', - 'Pull requests and commit statuses for detected repositories' - ) - : translate( - 'auto.components.settings.IntegrationsPane.6bd148dcb5', - 'Pull requests and commit statuses via the Gitea REST API.' - )} -

-
- {giteaStatus === 'checking' ? ( - - ) : giteaStatus === 'configured' ? ( - - {giteaAccount - ? translate('auto.components.settings.IntegrationsPane.6432f6522e', 'Connected') - : translate('auto.components.settings.IntegrationsPane.e7a961e1c5', 'Configured')} - - ) : ( - - {giteaStatus === 'not-configured' - ? translate( - 'auto.components.settings.IntegrationsPane.e1bd5364e6', - 'Optional setup' - ) - : translate('auto.components.settings.IntegrationsPane.45bf5e6e4b', 'Auth failed')} - - )} -
- - {giteaStatus !== 'checking' && giteaStatus !== 'configured' && ( -
- {giteaStatus === 'not-configured' ? ( - <> -

- {translate( - 'auto.components.settings.IntegrationsPane.d9467ab026', - 'Public repositories are detected from their git remote. Set' - )}{' '} - - {translate( - 'auto.components.settings.IntegrationsPane.e678d89e8c', - 'ORCA_GITEA_TOKEN' - )} - {' '} - {translate( - 'auto.components.settings.IntegrationsPane.2c0330ec3e', - 'for private repositories, and set' - )}{' '} - - {translate( - 'auto.components.settings.IntegrationsPane.6193444689', - 'ORCA_GITEA_API_BASE_URL' - )} - {' '} - {translate( - 'auto.components.settings.IntegrationsPane.5a1f86225a', - 'only when Orca cannot derive the API URL from the remote.' - )} -

-
- - -
- - ) : ( - <> -

- {translate( - 'auto.components.settings.IntegrationsPane.1a62c295c6', - 'Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.' - )} -

-
- - -
- - )} -
- )} -
- - {/* Linear */} -
-
- -
-

- {translate('auto.components.settings.IntegrationsPane.264a9b6128', 'Linear')} -

-

- {linearStatus.connected - ? translate( - 'auto.components.settings.IntegrationsPane.98ded79cd7', - '{{value0}} workspace{{value1}} connected', - { - value0: linearWorkspaces.length, - value1: linearWorkspaces.length === 1 ? '' : 's' - } - ) - : translate( - 'auto.components.settings.IntegrationsPane.33ae9730a8', - 'Add Linear access to browse and link issues.' - )} -

- {linearStatus.credentialError ? ( -

- - {linearStatus.credentialError} -

- ) : null} -
- {linearStatus.connected ? ( -
- - - {translate('auto.components.settings.IntegrationsPane.6432f6522e', 'Connected')} - -
- ) : ( - - )} -
- - {linearStatus.connected && ( -
- {linearWorkspaces.map((workspace) => { - const testResult = linearTestResultByWorkspace[workspace.id] - const testing = linearTestingWorkspaceId === workspace.id - return ( -
-
-

- {workspace.organizationName} -

-

- {workspace.displayName} - {workspace.email ? ` · ${workspace.email}` : ''} -

-
- {testResult?.state === 'ok' ? ( - - - {translate( - 'auto.components.settings.IntegrationsPane.fe4d378dc4', - 'Verified' - )} - - ) : null} - {testResult?.state === 'error' ? ( - - - {testResult.error} - - ) : null} - - -
- ) - })} -

- {translate( - 'auto.components.settings.IntegrationsPane.2122e15517', - 'Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.' - )} -

-
- )} -
- - - - setLinearTestResultByWorkspace({})} - /> +
) } diff --git a/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx b/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx new file mode 100644 index 00000000000..33332c451f8 --- /dev/null +++ b/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx @@ -0,0 +1,265 @@ +import { ExternalLink, Github, Gitlab, Terminal } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell' +import { usePreflightCardStatuses } from './source-control-preflight-card-status' +import { translate } from '@/i18n/i18n' + +export function GitHubIntegrationCard(): React.JSX.Element { + const { statuses, unavailable, refresh } = usePreflightCardStatuses('gh') + const status = unavailable ? 'unavailable' : statuses.ghStatus + const connected = status === 'connected' + + return ( + } + name="GitHub" + description={ + <> + {translate( + 'auto.components.settings.cli.source.control.integration.cards.b4d900e7f1', + 'Pull requests, issues, and checks via the' + )}{' '} + + {translate( + 'auto.components.settings.cli.source.control.integration.cards.6b2cfb52b4', + 'gh' + )} + {' '} + {translate( + 'auto.components.settings.cli.source.control.integration.cards.a47f71e357', + 'CLI.' + )} + + } + checking={status === 'checking'} + statusTone={connected ? 'connected' : 'attention'} + statusLabel={ + connected + ? 'Connected' + : status === 'unavailable' + ? 'Unavailable' + : status === 'not-installed' + ? 'Not installed' + : 'Not authenticated' + } + > + {status !== 'checking' && !connected ? ( + + {status === 'unavailable' ? ( + <> +

+ {translate( + 'auto.components.settings.cli.source.control.integration.cards.6f30fc4216', + 'GitHub CLI status is not available in this runtime yet.' + )} +

+ + + ) : status === 'not-installed' ? ( + <> +

+ {translate( + 'auto.components.settings.cli.source.control.integration.cards.23cb5a0dee', + 'Install the GitHub CLI to enable pull requests, issues, and checks.' + )} +

+
+ + +
+ + ) : ( + <> +

+ {translate( + 'auto.components.settings.cli.source.control.integration.cards.2e44dda68a', + 'The GitHub CLI is installed but not authenticated. Run this command in a terminal:' + )} +

+
+ + {translate( + 'auto.components.settings.cli.source.control.integration.cards.8d90249d22', + 'gh auth login' + )} +
+
+ + +
+ + )} +
+ ) : null} +
+ ) +} + +export function GitLabIntegrationCard(): React.JSX.Element { + const { statuses, unavailable, refresh } = usePreflightCardStatuses('glab') + const status = unavailable ? 'unavailable' : statuses.glabStatus + const connected = status === 'connected' + + return ( + } + name="GitLab" + description={ + <> + {translate( + 'auto.components.settings.cli.source.control.integration.cards.1f2b347bd3', + 'Merge requests, issues, todos, and pipelines via the' + )}{' '} + + {translate( + 'auto.components.settings.cli.source.control.integration.cards.2a6b359e75', + 'glab' + )} + {' '} + {translate( + 'auto.components.settings.cli.source.control.integration.cards.a47f71e357', + 'CLI.' + )} + + } + checking={status === 'checking'} + statusTone={connected ? 'connected' : 'attention'} + statusLabel={ + connected + ? 'Connected' + : status === 'unavailable' + ? 'Unavailable' + : status === 'not-installed' + ? 'Not installed' + : 'Not authenticated' + } + > + {status !== 'checking' && !connected ? ( + + {status === 'unavailable' ? ( + <> +

+ {translate( + 'auto.components.settings.cli.source.control.integration.cards.faddeb763d', + 'GitLab CLI status is not available in this runtime yet.' + )} +

+ + + ) : status === 'not-installed' ? ( + <> +

+ {translate( + 'auto.components.settings.cli.source.control.integration.cards.b56fd5676a', + 'Install the GitLab CLI to enable merge requests, issues, and pipelines.' + )} +

+
+ + +
+ + ) : ( + <> +

+ {translate( + 'auto.components.settings.cli.source.control.integration.cards.4be0616873', + 'The GitLab CLI is installed but not authenticated. Run this command in a terminal:' + )} +

+
+ + {translate( + 'auto.components.settings.cli.source.control.integration.cards.707180d09c', + 'glab auth login' + )} +
+
+ + +
+ + )} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/integration-card-shell.tsx b/src/renderer/src/components/settings/integration-card-shell.tsx new file mode 100644 index 00000000000..898619f96c4 --- /dev/null +++ b/src/renderer/src/components/settings/integration-card-shell.tsx @@ -0,0 +1,68 @@ +import { LoaderCircle } from 'lucide-react' +import { cn } from '@/lib/utils' + +export type IntegrationCardStatusTone = 'connected' | 'attention' | 'neutral' + +const STATUS_TONE_CLASSES: Record = { + connected: 'border-status-success-border bg-status-success-background text-status-success', + attention: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300', + neutral: 'border-border bg-background text-muted-foreground' +} + +export function IntegrationCardShell(props: { + icon: React.ReactNode + name: string + description: React.ReactNode + statusLabel: string + statusTone: IntegrationCardStatusTone + checking?: boolean + className?: string + actions?: React.ReactNode + children?: React.ReactNode +}): React.JSX.Element { + return ( +
+
+ {props.icon} +
+

{props.name}

+

{props.description}

+
+ {props.actions ? ( +
{props.actions}
+ ) : null} + {props.checking ? ( + + ) : ( + + {props.statusLabel} + + )} +
+ {props.children} +
+ ) +} + +export function IntegrationCardDetails(props: { + className?: string + children: React.ReactNode +}): React.JSX.Element { + return ( +
+ {props.children} +
+ ) +} diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index 9abdb29c210..bd27b1cc8ed 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -1,100 +1,47 @@ -import { useEffect, useState } from 'react' -import { AlertCircle, CheckCircle2, ExternalLink, LoaderCircle, Unlink } from 'lucide-react' +import { useState } from 'react' +import { AlertCircle, CheckCircle2, LoaderCircle, Unlink } from 'lucide-react' +import { JiraConnectDialog } from '@/components/jira-connect-dialog' import { JiraIcon } from '@/components/icons/JiraIcon' +import { Button } from '@/components/ui/button' import { useMountedRef } from '@/hooks/useMountedRef' +import { + getProviderRuntimeContextKey, + hasRemoteProviderRuntime +} from '@/lib/provider-runtime-context' import { useAppStore } from '@/store' -import type { JiraSite } from '../../../../shared/types' -import { Button } from '../ui/button' -import { Input } from '../ui/input' +import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell' import { translate } from '@/i18n/i18n' -type JiraFormMode = - | { kind: 'add' } - | { - kind: 'update' - site: JiraSite - } - -type JiraTestResult = { state: 'ok' | 'error'; error?: string } - -function getJiraSiteLabel(site: JiraSite): string { - return `${site.displayName} · ${site.email}` -} +type VerificationResult = { state: 'ok' | 'error'; error?: string } export function JiraIntegrationCard(): React.JSX.Element { const jiraStatus = useAppStore((s) => s.jiraStatus) + const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked) + const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey) const checkJiraConnection = useAppStore((s) => s.checkJiraConnection) - const connectJira = useAppStore((s) => s.connectJira) const disconnectJira = useAppStore((s) => s.disconnectJira) const testJiraConnection = useAppStore((s) => s.testJiraConnection) + const settings = useAppStore((s) => s.settings) const mountedRef = useMountedRef() - const [formMode, setFormMode] = useState(null) - const [siteUrlDraft, setSiteUrlDraft] = useState('') - const [emailDraft, setEmailDraft] = useState('') - const [apiTokenDraft, setApiTokenDraft] = useState('') - const [connectState, setConnectState] = useState<'idle' | 'connecting' | 'error'>('idle') - const [connectError, setConnectError] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) const [testingSiteId, setTestingSiteId] = useState(null) - const [testResultBySite, setTestResultBySite] = useState>({}) + const [testResultBySite, setTestResultBySite] = useState>({}) - const jiraSites = jiraStatus.sites ?? [] + const contextMatches = jiraStatusContextKey === getProviderRuntimeContextKey(settings) + const checking = !contextMatches || !jiraStatusChecked + const connected = contextMatches && jiraStatus.connected + const sites = jiraStatus.sites ?? [] + const siteCount = sites.length || (connected ? 1 : 0) + const credentialCopy = hasRemoteProviderRuntime(settings) + ? 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.' + : 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are stored locally and encrypted when local runtime storage supports it.' - useEffect(() => { - void checkJiraConnection() - }, [checkJiraConnection]) - - const openAddForm = (): void => { - setFormMode({ kind: 'add' }) - setSiteUrlDraft('') - setEmailDraft('') - setApiTokenDraft('') - setConnectState('idle') - setConnectError(null) - } - - const openUpdateForm = (site: JiraSite): void => { - setFormMode({ kind: 'update', site }) - setSiteUrlDraft(site.siteUrl) - setEmailDraft(site.email) - setApiTokenDraft('') - setConnectState('idle') - setConnectError(null) - } - - const closeForm = (): void => { - if (connectState === 'connecting') { - return - } - setFormMode(null) - setConnectError(null) - setConnectState('idle') - } - - const handleConnect = async (): Promise => { - const siteUrl = siteUrlDraft.trim() - const email = emailDraft.trim() - const apiToken = apiTokenDraft.trim() - if (!siteUrl || !email || !apiToken) { - return - } - setConnectState('connecting') - setConnectError(null) - const result = await connectJira({ siteUrl, email, apiToken }) - if (!mountedRef.current) { - return - } - if (result.ok) { - setFormMode(null) - setSiteUrlDraft('') - setEmailDraft('') - setApiTokenDraft('') - setConnectState('idle') + const handleDisconnect = async (siteId?: string): Promise => { + await disconnectJira(siteId) + if (mountedRef.current) { setTestResultBySite({}) - return } - setConnectState('error') - setConnectError(result.error) } const handleTest = async (siteId: string): Promise => { @@ -115,167 +62,53 @@ export function JiraIntegrationCard(): React.JSX.Element { setTestingSiteId(null) } - const handleDisconnect = async (siteId: string): Promise => { - await disconnectJira(siteId) - if (!mountedRef.current) { - return - } - setTestResultBySite((prev) => { - const next = { ...prev } - delete next[siteId] - return next - }) - } - return ( -
-
- -
-

- {translate('auto.components.settings.jira.integration.card.09742875cd', 'Jira')} -

-

- {jiraStatus.connected + } + name="Jira" + description={ + connected + ? translate( + 'auto.components.settings.task.tracker.integration.cards.9fa04a032e', + '{{value0}} site{{value1}} connected', + { value0: siteCount, value1: siteCount === 1 ? '' : 's' } + ) + : checking + ? translate( + 'auto.components.settings.task.tracker.integration.cards.a1093a06c7', + 'Checking Jira access before showing setup actions.' + ) + : translate( + 'auto.components.settings.task.tracker.integration.cards.7ca5ffffdb', + 'Browse, create, and start work from Jira Cloud issues.' + ) + } + checking={checking} + statusTone={connected ? 'connected' : 'attention'} + statusLabel={connected ? 'Connected' : 'Not connected'} + actions={ + !checking ? ( +

- {jiraStatus.connected ? ( -
- - - {translate('auto.components.settings.jira.integration.card.9bb34706ca', 'Connected')} - -
- ) : ( - - )} -
- - {formMode ? ( -
-
- { - setSiteUrlDraft(e.target.value) - setConnectError(null) - setConnectState('idle') - }} - disabled={connectState === 'connecting'} - /> - { - setEmailDraft(e.target.value) - setConnectError(null) - setConnectState('idle') - }} - disabled={connectState === 'connecting'} - /> - { - setApiTokenDraft(e.target.value) - setConnectError(null) - setConnectState('idle') - }} - disabled={connectState === 'connecting'} - /> -
- {connectState === 'error' && connectError ? ( -

{connectError}

- ) : null} -
- -
- - -
-
-
- ) : null} - - {jiraStatus.connected ? ( + + ) : null + } + > + {connected && sites.length > 0 ? (
- {jiraSites.map((site) => { + {sites.map((site) => { const testResult = testResultBySite[site.id] const testing = testingSiteId === site.id return ( @@ -284,16 +117,17 @@ export function JiraIntegrationCard(): React.JSX.Element { className="flex items-center gap-3 rounded-md border border-border/50 bg-background/60 px-3 py-2" >
-

- {getJiraSiteLabel(site)} +

{site.displayName}

+

+ {site.siteUrl} + {site.email ? ` · ${site.email}` : ''}

-

{site.siteUrl}

{testResult?.state === 'ok' ? ( - + {translate( - 'auto.components.settings.jira.integration.card.ab350991b8', + 'auto.components.settings.task.tracker.integration.cards.a2c0015fb8', 'Verified' )} @@ -314,23 +148,23 @@ export function JiraIntegrationCard(): React.JSX.Element { <> {translate( - 'auto.components.settings.jira.integration.card.cec06a0f79', - 'Testing…' + 'auto.components.settings.task.tracker.integration.cards.3e7c10d286', + 'Testing...' )} ) : ( - translate('auto.components.settings.jira.integration.card.255bfe98ec', 'Test') + translate( + 'auto.components.settings.task.tracker.integration.cards.c24e56c532', + 'Test' + ) )} -
+ ) : connected ? ( + +

+ {translate( + 'auto.components.settings.task.tracker.integration.cards.8b2408a8e5', + 'Jira is connected for this runtime. Re-check if the connected site list looks stale.' + )} +

+
+ + +
+
+ ) : !checking ? ( + +

{credentialCopy}

+ +
) : null} -
+ + setTestResultBySite({})} + overlayClassName="z-[110]" + contentClassName="z-[120]" + /> + ) } diff --git a/src/renderer/src/components/settings/source-control-integration-cards.tsx b/src/renderer/src/components/settings/source-control-integration-cards.tsx new file mode 100644 index 00000000000..f43b1003d43 --- /dev/null +++ b/src/renderer/src/components/settings/source-control-integration-cards.tsx @@ -0,0 +1,11 @@ +export { + GitHubIntegrationCard, + GitLabIntegrationCard +} from './cli-source-control-integration-cards' +export { deriveCliProviderCardState } from './source-control-preflight-card-status' +export type { CliProviderCardState } from './source-control-preflight-card-status' +export { + AzureDevOpsIntegrationCard, + BitbucketIntegrationCard, + GiteaIntegrationCard +} from './token-source-control-integration-cards' diff --git a/src/renderer/src/components/settings/source-control-preflight-card-status.ts b/src/renderer/src/components/settings/source-control-preflight-card-status.ts new file mode 100644 index 00000000000..b4b3935b535 --- /dev/null +++ b/src/renderer/src/components/settings/source-control-preflight-card-status.ts @@ -0,0 +1,95 @@ +import { useState } from 'react' +import { useMountedRef } from '@/hooks/useMountedRef' +import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' +import { useAppStore } from '@/store' +import { + getPreflightIntegrationStatuses, + type PreflightIntegrationStatuses, + type PreflightRefreshProvider +} from './integrations-pane-status' + +type CliStatus = { + installed?: boolean + authenticated?: boolean +} + +export type CliProviderCardState = + | 'checking' + | 'connected' + | 'not-installed' + | 'not-authenticated' + | 'unavailable' + +export function deriveCliProviderCardState(input: { + cliStatus?: CliStatus + preflightStatusAvailable: boolean + preflightStatusChecked: boolean + preflightStatusCurrent: boolean + preflightStatusError: string | null + preflightStatusLoading: boolean +}): CliProviderCardState { + if ( + input.preflightStatusLoading || + !input.preflightStatusChecked || + !input.preflightStatusCurrent + ) { + return 'checking' + } + if (input.preflightStatusError !== null || !input.preflightStatusAvailable || !input.cliStatus) { + return 'unavailable' + } + if (!input.cliStatus.installed) { + return 'not-installed' + } + return input.cliStatus.authenticated ? 'connected' : 'not-authenticated' +} + +export type PreflightCardStatuses = { + statuses: PreflightIntegrationStatuses + unavailable: boolean + refresh: () => void +} + +export function usePreflightCardStatuses( + provider: PreflightRefreshProvider +): PreflightCardStatuses { + const preflightStatus = useAppStore((s) => s.preflightStatus) + const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) + const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey) + const preflightStatusError = useAppStore((s) => s.preflightStatusError) + const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading) + const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + const expectedPreflightContextKey = useAppStore((s) => + localPreflightContextKey(getLocalPreflightContext(s)) + ) + const mountedRef = useMountedRef() + const [refreshing, setRefreshing] = useState(false) + const refreshingProviders: ReadonlySet = refreshing + ? new Set([provider]) + : new Set() + const preflightCurrent = preflightStatusContextKey === expectedPreflightContextKey + const unavailable = + !preflightStatusLoading && + preflightStatusChecked && + preflightCurrent && + preflightStatusError !== null + const statusInput = + !preflightStatusLoading && preflightStatusChecked && preflightCurrent && !unavailable + ? preflightStatus + : null + + const refresh = (): void => { + setRefreshing(true) + void refreshPreflightStatus({ force: true }).finally(() => { + if (mountedRef.current) { + setRefreshing(false) + } + }) + } + + return { + statuses: getPreflightIntegrationStatuses(statusInput, refreshingProviders), + unavailable, + refresh + } +} diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx new file mode 100644 index 00000000000..c73686da024 --- /dev/null +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx @@ -0,0 +1,212 @@ +import { useState } from 'react' +import { AlertCircle, CheckCircle2, LoaderCircle, Unlink } from 'lucide-react' +import { LinearIcon } from '@/components/icons/LinearIcon' +import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' +import { Button } from '@/components/ui/button' +import { useMountedRef } from '@/hooks/useMountedRef' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { useAppStore } from '@/store' +import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell' +import { translate } from '@/i18n/i18n' + +type VerificationResult = { state: 'ok' | 'error'; error?: string } + +export function LinearIntegrationCard(): React.JSX.Element { + const linearStatus = useAppStore((s) => s.linearStatus) + const linearStatusChecked = useAppStore((s) => s.linearStatusChecked) + const linearStatusContextKey = useAppStore((s) => s.linearStatusContextKey) + const disconnectLinear = useAppStore((s) => s.disconnectLinear) + const disconnectLinearWorkspace = useAppStore((s) => s.disconnectLinearWorkspace) + const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) + const testLinearConnection = useAppStore((s) => s.testLinearConnection) + const settings = useAppStore((s) => s.settings) + const mountedRef = useMountedRef() + + const [dialogOpen, setDialogOpen] = useState(false) + const [testingWorkspaceId, setTestingWorkspaceId] = useState(null) + const [testResultByWorkspace, setTestResultByWorkspace] = useState< + Record + >({}) + + const contextMatches = linearStatusContextKey === getProviderRuntimeContextKey(settings) + const checking = !contextMatches || !linearStatusChecked + const connected = contextMatches && linearStatus.connected + const workspaces = linearStatus.workspaces ?? [] + + const handleDisconnect = async (workspaceId?: string): Promise => { + await (workspaceId ? disconnectLinearWorkspace(workspaceId) : disconnectLinear()) + if (mountedRef.current) { + setTestResultByWorkspace({}) + } + } + + // Why: explicit user-triggered verification. This is the only settings path + // that decrypts a stored Linear key, avoiding surprise keychain prompts. + const handleTest = async (workspaceId: string): Promise => { + setTestingWorkspaceId(workspaceId) + setTestResultByWorkspace((prev) => { + const next = { ...prev } + delete next[workspaceId] + return next + }) + const result = await testLinearConnection(workspaceId) + if (!mountedRef.current) { + return + } + setTestResultByWorkspace((prev) => ({ + ...prev, + [workspaceId]: result.ok ? { state: 'ok' } : { state: 'error', error: result.error } + })) + setTestingWorkspaceId(null) + } + + return ( + } + name="Linear" + description={ + connected + ? translate( + 'auto.components.settings.task.tracker.integration.cards.e1f5e6424c', + '{{value0}} workspace{{value1}} connected', + { value0: workspaces.length, value1: workspaces.length === 1 ? '' : 's' } + ) + : checking + ? translate( + 'auto.components.settings.task.tracker.integration.cards.fe9231215b', + 'Checking Linear access before showing setup actions.' + ) + : translate( + 'auto.components.settings.task.tracker.integration.cards.eae4a9f16b', + 'Add Linear access to browse and link issues.' + ) + } + checking={checking} + statusTone={connected ? 'connected' : 'attention'} + statusLabel={connected ? 'Connected' : 'Not connected'} + actions={ + !checking ? ( + + ) : null + } + > + {connected ? ( +
+ {workspaces.map((workspace) => { + const testResult = testResultByWorkspace[workspace.id] + const testing = testingWorkspaceId === workspace.id + return ( +
+
+

+ {workspace.organizationName} +

+

+ {workspace.displayName} + {workspace.email ? ` · ${workspace.email}` : ''} +

+
+ {testResult?.state === 'ok' ? ( + + + {translate( + 'auto.components.settings.task.tracker.integration.cards.a2c0015fb8', + 'Verified' + )} + + ) : null} + {testResult?.state === 'error' ? ( + + + {testResult.error} + + ) : null} + + +
+ ) + })} +

+ {translate( + 'auto.components.settings.task.tracker.integration.cards.6224fe9d34', + 'Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.' + )} +

+
+ ) : !checking ? ( + +

+ {translate( + 'auto.components.settings.task.tracker.integration.cards.cef18762a2', + 'Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.' + )} +

+ +
+ ) : null} + + setTestResultByWorkspace({})} + overlayClassName="z-[110]" + contentClassName="z-[120]" + /> +
+ ) +} + +export { JiraIntegrationCard } from './jira-integration-card' diff --git a/src/renderer/src/components/settings/token-source-control-integration-cards.tsx b/src/renderer/src/components/settings/token-source-control-integration-cards.tsx new file mode 100644 index 00000000000..fe83e1e6b62 --- /dev/null +++ b/src/renderer/src/components/settings/token-source-control-integration-cards.tsx @@ -0,0 +1,365 @@ +import { ExternalLink, GitPullRequestArrow } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell' +import { usePreflightCardStatuses } from './source-control-preflight-card-status' +import { translate } from '@/i18n/i18n' + +export function BitbucketIntegrationCard(): React.JSX.Element { + const { statuses, unavailable, refresh } = usePreflightCardStatuses('bitbucket') + const status = unavailable ? 'unavailable' : statuses.bitbucketStatus + const connected = status === 'connected' + + return ( + } + name="Bitbucket" + description={ + connected + ? statuses.bitbucketAccount + ? translate( + 'auto.components.settings.token.source.control.integration.cards.ea204f5e03', + '{{value0}} · Pull requests and build statuses', + { value0: statuses.bitbucketAccount } + ) + : translate( + 'auto.components.settings.token.source.control.integration.cards.0fa5629dad', + 'Pull requests and build statuses' + ) + : translate( + 'auto.components.settings.token.source.control.integration.cards.a924e8dcd1', + 'Pull requests and build statuses via Bitbucket Cloud API tokens.' + ) + } + checking={status === 'checking'} + statusTone={connected ? 'connected' : 'attention'} + statusLabel={ + connected + ? 'Connected' + : status === 'unavailable' + ? 'Unavailable' + : status === 'not-configured' + ? 'Not configured' + : 'Auth failed' + } + > + {status !== 'checking' && !connected ? ( + +

+ {status === 'unavailable' ? ( + translate( + 'auto.components.settings.token.source.control.integration.cards.24ac1c69dc', + 'Bitbucket status is not available in this runtime yet.' + ) + ) : status === 'not-configured' ? ( + <> + {translate( + 'auto.components.settings.token.source.control.integration.cards.7bbc9c64f0', + 'Set' + )}{' '} + + {translate( + 'auto.components.settings.token.source.control.integration.cards.63a7f47392', + 'ORCA_BITBUCKET_EMAIL' + )} + {' '} + {translate( + 'auto.components.settings.token.source.control.integration.cards.fc71a0e7aa', + 'and' + )}{' '} + + {translate( + 'auto.components.settings.token.source.control.integration.cards.19416c874c', + 'ORCA_BITBUCKET_API_TOKEN' + )} + + {translate( + 'auto.components.settings.token.source.control.integration.cards.087feb92f1', + ', or set' + )}{' '} + + {translate( + 'auto.components.settings.token.source.control.integration.cards.e63fe8f627', + 'ORCA_BITBUCKET_ACCESS_TOKEN' + )} + + . + + ) : ( + translate( + 'auto.components.settings.token.source.control.integration.cards.6154b02093', + 'Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.' + ) + )} +

+
+ + +
+
+ ) : null} +
+ ) +} + +export function AzureDevOpsIntegrationCard(): React.JSX.Element { + const { statuses, unavailable, refresh } = usePreflightCardStatuses('azureDevOps') + const status = unavailable ? 'unavailable' : statuses.azureDevOpsStatus + const configured = status === 'configured' + + return ( + } + name="Azure DevOps" + description={ + configured + ? statuses.azureDevOpsAccount + ? translate( + 'auto.components.settings.token.source.control.integration.cards.ea204f5e03', + '{{value0}} · Pull requests and build statuses', + { value0: statuses.azureDevOpsAccount } + ) + : statuses.azureDevOpsBaseUrl + ? translate( + 'auto.components.settings.token.source.control.integration.cards.ea204f5e03', + '{{value0}} · Pull requests and build statuses', + { value0: statuses.azureDevOpsBaseUrl } + ) + : translate( + 'auto.components.settings.token.source.control.integration.cards.54636c65d4', + 'Pull requests and build statuses for detected Azure Repos' + ) + : translate( + 'auto.components.settings.token.source.control.integration.cards.0eb50d5593', + 'Pull requests and build statuses via Azure DevOps REST API tokens.' + ) + } + checking={status === 'checking'} + statusTone={configured ? 'connected' : 'attention'} + statusLabel={ + configured + ? statuses.azureDevOpsAccount + ? 'Connected' + : 'Configured' + : status === 'unavailable' + ? 'Unavailable' + : status === 'not-configured' + ? 'Not configured' + : 'Auth failed' + } + > + {status !== 'checking' && !configured ? ( + +

+ {status === 'unavailable' ? ( + translate( + 'auto.components.settings.token.source.control.integration.cards.f3f47dc7de', + 'Azure DevOps status is not available in this runtime yet.' + ) + ) : status === 'not-configured' ? ( + <> + {translate( + 'auto.components.settings.token.source.control.integration.cards.7bbc9c64f0', + 'Set' + )}{' '} + + {translate( + 'auto.components.settings.token.source.control.integration.cards.48842720d2', + 'ORCA_AZURE_DEVOPS_TOKEN' + )} + + {translate( + 'auto.components.settings.token.source.control.integration.cards.087feb92f1', + ', or set' + )}{' '} + + {translate( + 'auto.components.settings.token.source.control.integration.cards.fbfd237f5e', + 'ORCA_AZURE_DEVOPS_ACCESS_TOKEN' + )} + + {translate( + 'auto.components.settings.token.source.control.integration.cards.b8a10b07c1', + '. Set' + )}{' '} + + {translate( + 'auto.components.settings.token.source.control.integration.cards.186a6689df', + 'ORCA_AZURE_DEVOPS_API_BASE_URL' + )} + {' '} + {translate( + 'auto.components.settings.token.source.control.integration.cards.7bd345e3f6', + 'only when Orca cannot derive the API base URL from the git remote.' + )} + + ) : ( + translate( + 'auto.components.settings.token.source.control.integration.cards.40f678df73', + 'Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.' + ) + )} +

+
+ + +
+
+ ) : null} +
+ ) +} + +export function GiteaIntegrationCard(): React.JSX.Element { + const { statuses, unavailable, refresh } = usePreflightCardStatuses('gitea') + const status = unavailable ? 'unavailable' : statuses.giteaStatus + const configured = status === 'configured' + + return ( + } + name="Gitea" + description={ + configured + ? statuses.giteaAccount + ? translate( + 'auto.components.settings.token.source.control.integration.cards.0b5242f8a2', + '{{value0}} · Pull requests and commit statuses', + { value0: statuses.giteaAccount } + ) + : statuses.giteaBaseUrl + ? translate( + 'auto.components.settings.token.source.control.integration.cards.0b5242f8a2', + '{{value0}} · Pull requests and commit statuses', + { value0: statuses.giteaBaseUrl } + ) + : translate( + 'auto.components.settings.token.source.control.integration.cards.52f75876be', + 'Pull requests and commit statuses for detected repositories' + ) + : translate( + 'auto.components.settings.token.source.control.integration.cards.05863d2599', + 'Pull requests and commit statuses via the Gitea REST API.' + ) + } + checking={status === 'checking'} + statusTone={configured ? 'connected' : 'attention'} + statusLabel={ + configured + ? statuses.giteaAccount + ? 'Connected' + : 'Configured' + : status === 'unavailable' + ? 'Unavailable' + : status === 'not-configured' + ? 'Optional setup' + : 'Auth failed' + } + > + {status !== 'checking' && !configured ? ( + +

+ {status === 'unavailable' ? ( + translate( + 'auto.components.settings.token.source.control.integration.cards.0613928cb3', + 'Gitea status is not available in this runtime yet.' + ) + ) : status === 'not-configured' ? ( + <> + {translate( + 'auto.components.settings.token.source.control.integration.cards.fcbe0469fd', + 'Public repositories are detected from their git remote. Set' + )}{' '} + + {translate( + 'auto.components.settings.token.source.control.integration.cards.6d5c2a3005', + 'ORCA_GITEA_TOKEN' + )} + {' '} + {translate( + 'auto.components.settings.token.source.control.integration.cards.6da9dfa5de', + 'for private repositories, and set' + )}{' '} + + {translate( + 'auto.components.settings.token.source.control.integration.cards.709057ad91', + 'ORCA_GITEA_API_BASE_URL' + )} + {' '} + {translate( + 'auto.components.settings.token.source.control.integration.cards.60708f23da', + 'only when Orca cannot derive the API URL from the remote.' + )} + + ) : ( + translate( + 'auto.components.settings.token.source.control.integration.cards.19fb419c12', + 'Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.' + ) + )} +

+
+ + +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/use-integration-provider-status-refresh.ts b/src/renderer/src/components/settings/use-integration-provider-status-refresh.ts new file mode 100644 index 00000000000..465477d3ade --- /dev/null +++ b/src/renderer/src/components/settings/use-integration-provider-status-refresh.ts @@ -0,0 +1,51 @@ +import { useEffect } from 'react' +import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { useAppStore } from '@/store' + +export function useIntegrationProviderStatusRefresh(): void { + const settings = useAppStore((s) => s.settings) + const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) + const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey) + const linearStatusChecked = useAppStore((s) => s.linearStatusChecked) + const linearStatusContextKey = useAppStore((s) => s.linearStatusContextKey) + const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked) + const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey) + const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) + const checkJiraConnection = useAppStore((s) => s.checkJiraConnection) + const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + const expectedPreflightContextKey = useAppStore((s) => + localPreflightContextKey(getLocalPreflightContext(s)) + ) + const providerRuntimeContextKey = getProviderRuntimeContextKey(settings) + const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey + const linearStatusCurrent = linearStatusContextKey === providerRuntimeContextKey + const jiraStatusCurrent = jiraStatusContextKey === providerRuntimeContextKey + + useEffect(() => { + if (!linearStatusCurrent || !linearStatusChecked) { + void checkLinearConnection() + } + if (!jiraStatusCurrent || !jiraStatusChecked) { + void checkJiraConnection() + } + if (!preflightStatusCurrent || !preflightStatusChecked) { + void refreshPreflightStatus() + } + }, [ + checkJiraConnection, + checkLinearConnection, + jiraStatusChecked, + jiraStatusCurrent, + jiraStatusContextKey, + linearStatusChecked, + linearStatusCurrent, + linearStatusContextKey, + expectedPreflightContextKey, + preflightStatusChecked, + preflightStatusContextKey, + preflightStatusCurrent, + providerRuntimeContextKey, + refreshPreflightStatus + ]) +} diff --git a/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts b/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts index dfa4f7dae70..f74ffeaa612 100644 --- a/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts +++ b/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts @@ -13,6 +13,7 @@ export type SetupGuideProgressReadinessInput = { settingsLoaded: boolean preflightStatusChecked: boolean linearStatusChecked: boolean + jiraStatusChecked: boolean browserUseSkillDiscoveryLoading: boolean computerUseSkillDiscoveryLoading: boolean orchestrationSkillDiscoveryLoading: boolean @@ -82,6 +83,7 @@ export function getSetupGuideProgressReady(input: SetupGuideProgressReadinessInp input.settingsLoaded && input.preflightStatusChecked && input.linearStatusChecked && + input.jiraStatusChecked && !input.browserUseSkillDiscoveryLoading && !input.computerUseSkillDiscoveryLoading && !input.orchestrationSkillDiscoveryLoading && diff --git a/src/renderer/src/components/setup-guide/setup-script-probe-cache.ts b/src/renderer/src/components/setup-guide/setup-script-probe-cache.ts new file mode 100644 index 00000000000..366f41e0e1d --- /dev/null +++ b/src/renderer/src/components/setup-guide/setup-script-probe-cache.ts @@ -0,0 +1,34 @@ +import { + INITIAL_SETUP_SCRIPT_PROBE_STATE, + type SetupScriptProbeState +} from './setup-guide-progress-readiness' + +// Why: probe results are shared across every mounted setup-guide consumer so a +// single bounded probe can settle readiness for all of them at once. +const setupScriptProbeCacheListeners = new Set<() => void>() +let setupScriptProbeCache = INITIAL_SETUP_SCRIPT_PROBE_STATE + +export function readSetupScriptProbeCache(): SetupScriptProbeState { + return setupScriptProbeCache +} + +export function subscribeSetupScriptProbeCache(listener: () => void): () => void { + setupScriptProbeCacheListeners.add(listener) + return () => { + setupScriptProbeCacheListeners.delete(listener) + } +} + +export function setSetupScriptProbeCache(next: SetupScriptProbeState): void { + if ( + setupScriptProbeCache.signature === next.signature && + setupScriptProbeCache.ready === next.ready && + setupScriptProbeCache.hasSetupScript === next.hasSetupScript + ) { + return + } + setupScriptProbeCache = next + for (const listener of setupScriptProbeCacheListeners) { + listener() + } +} diff --git a/src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts b/src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts index 48c514b43c5..7062696e74a 100644 --- a/src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts +++ b/src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts @@ -137,6 +137,7 @@ describe('getSetupGuideProgressReady', () => { settingsLoaded: true, preflightStatusChecked: true, linearStatusChecked: true, + jiraStatusChecked: true, browserUseSkillDiscoveryLoading: false, computerUseSkillDiscoveryLoading: false, orchestrationSkillDiscoveryLoading: false, @@ -194,9 +195,10 @@ describe('getSetupGuideProgressReady', () => { ).toBe(false) }) - it('waits for preflight and Linear checks', () => { + it('waits for preflight, Linear, and Jira checks', () => { expect(getSetupGuideProgressReady({ ...readyInput, preflightStatusChecked: false })).toBe(false) expect(getSetupGuideProgressReady({ ...readyInput, linearStatusChecked: false })).toBe(false) + expect(getSetupGuideProgressReady({ ...readyInput, jiraStatusChecked: false })).toBe(false) }) }) diff --git a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts index dab9ef02d4f..96e3673da59 100644 --- a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts +++ b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts @@ -3,7 +3,9 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore import { useAppStore } from '@/store' import { isGitRepoKind } from '../../../../shared/repo-kind' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' +import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { hasEffectiveSetupCommand } from '@/lib/setup-script-status' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { COMPUTER_USE_SKILL_NAME, ORCA_CLI_SKILL_NAME, @@ -17,46 +19,22 @@ import { getFeatureWallSetupProgress, type FeatureWallSetupProgress } from '../feature-wall/feature-wall-setup-progress' +import { deriveIntegrationConnectionStatus } from '../feature-wall/use-integration-connection-status' import { useSetupGuideBrowserMilestoneProgress } from './setup-guide-browser-milestone-progress' import { getComputerUsePermissionSetupState, getCurrentSetupScriptProbeState, getSetupGuideProgressReady, - getSetupScriptProbeSignature, - INITIAL_SETUP_SCRIPT_PROBE_STATE, - type SetupScriptProbeState + getSetupScriptProbeSignature } from './setup-guide-progress-readiness' +import { + readSetupScriptProbeCache, + setSetupScriptProbeCache, + subscribeSetupScriptProbeCache +} from './setup-script-probe-cache' const SETUP_SCRIPT_PROBE_SETTLE_TIMEOUT_MS = 15_000 -const setupScriptProbeCacheListeners = new Set<() => void>() -let setupScriptProbeCache = INITIAL_SETUP_SCRIPT_PROBE_STATE - -function readSetupScriptProbeCache(): SetupScriptProbeState { - return setupScriptProbeCache -} - -function subscribeSetupScriptProbeCache(listener: () => void): () => void { - setupScriptProbeCacheListeners.add(listener) - return () => { - setupScriptProbeCacheListeners.delete(listener) - } -} - -function setSetupScriptProbeCache(next: SetupScriptProbeState): void { - if ( - setupScriptProbeCache.signature === next.signature && - setupScriptProbeCache.ready === next.ready && - setupScriptProbeCache.hasSetupScript === next.hasSetupScript - ) { - return - } - setupScriptProbeCache = next - for (const listener of setupScriptProbeCacheListeners) { - listener() - } -} - export function useSetupGuideProgress( shouldRefreshCoreState: boolean, orchestrationSkillInstalled: boolean, @@ -69,12 +47,23 @@ export function useSetupGuideProgress( const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) const preflightStatus = useAppStore((s) => s.preflightStatus) const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) + const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey) + const preflightStatusError = useAppStore((s) => s.preflightStatusError) + const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading) const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) const linearStatus = useAppStore((s) => s.linearStatus) const linearStatusChecked = useAppStore((s) => s.linearStatusChecked) + const linearStatusContextKey = useAppStore((s) => s.linearStatusContextKey) const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) + const jiraStatus = useAppStore((s) => s.jiraStatus) + const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked) + const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey) + const checkJiraConnection = useAppStore((s) => s.checkJiraConnection) const repos = useAppStore((s) => s.repos) const activeRepoId = useAppStore((s) => s.activeRepoId) + const expectedPreflightContextKey = useAppStore((s) => + localPreflightContextKey(getLocalPreflightContext(s)) + ) const setupScriptProbe = useSyncExternalStore( subscribeSetupScriptProbeCache, readSetupScriptProbeCache, @@ -101,21 +90,38 @@ export function useSetupGuideProgress( enabled: shouldRefreshCoreState, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) + const providerRuntimeContextKey = getProviderRuntimeContextKey(settings) + const linearStatusCurrent = linearStatusContextKey === providerRuntimeContextKey + const jiraStatusCurrent = jiraStatusContextKey === providerRuntimeContextKey + const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey useEffect(() => { if (!shouldRefreshCoreState) { return } - if (!preflightStatusChecked) { + if (!preflightStatusCurrent || !preflightStatusChecked) { void refreshPreflightStatus() } - if (!linearStatusChecked) { + if (!linearStatusCurrent || !linearStatusChecked) { void checkLinearConnection() } + if (!jiraStatusCurrent || !jiraStatusChecked) { + void checkJiraConnection() + } }, [ + checkJiraConnection, checkLinearConnection, + jiraStatusCurrent, + jiraStatusChecked, + jiraStatusContextKey, + linearStatusCurrent, linearStatusChecked, + linearStatusContextKey, + expectedPreflightContextKey, + preflightStatusContextKey, + preflightStatusCurrent, preflightStatusChecked, + providerRuntimeContextKey, refreshPreflightStatus, shouldRefreshCoreState ]) @@ -185,6 +191,7 @@ export function useSetupGuideProgress( return } const permissionState = getComputerUsePermissionSetupState(status) + // oxlint-disable-next-line react-doctor/no-adjust-state-on-prop-change -- Why: async permission checks update setup progress after external OS state changes. setComputerUsePermissionStatusChecked(true) setComputerUsePermissionsReady(permissionState.ready) setComputerUseUnavailable(permissionState.unavailable) @@ -192,12 +199,18 @@ export function useSetupGuideProgress( useEffect(() => { if (!shouldRefreshCoreState || !computerUseSkillInstalled) { + // Why: unavailable setup-guide steps must clear stale permission state before + // readiness is derived for the visible checklist. + setComputerUsePermissionStatusChecked(false) + setComputerUsePermissionsReady(false) + setComputerUseUnavailable(false) return } let stale = false const refreshComputerUsePermissions = (): void => { void readComputerUsePermissions(() => stale) } + // oxlint-disable-next-line react-doctor/no-adjust-state-on-prop-change -- Why: refresh the setup checklist when the permission step becomes active. refreshComputerUsePermissions() const handleFocus = (): void => { void refreshComputerUsePermissions() @@ -218,10 +231,22 @@ export function useSetupGuideProgress( } }, [computerUseSkillInstalled, readComputerUsePermissions, shouldRefreshCoreState]) - const hasConnectedTaskSource = - (preflightStatus?.gh.installed === true && preflightStatus.gh.authenticated === true) || - (preflightStatus?.glab?.installed === true && preflightStatus.glab.authenticated === true) || - linearStatus.connected === true + const taskSourceStatus = deriveIntegrationConnectionStatus({ + preflightStatus, + preflightStatusChecked, + preflightStatusContextKey, + preflightStatusError, + preflightStatusLoading, + expectedPreflightContextKey, + linearStatus, + linearStatusChecked, + linearStatusContextKey, + jiraStatus, + jiraStatusChecked, + jiraStatusContextKey, + providerRuntimeContextKey + }) + const hasConnectedTaskSource = taskSourceStatus.trackerConnected const gitRepoCount = orderedGitRepos.length const currentSetupScriptProbe = getCurrentSetupScriptProbeState( setupScriptProbe, @@ -236,8 +261,11 @@ export function useSetupGuideProgress( const ready = getSetupGuideProgressReady({ refreshEnabled: shouldRefreshCoreState, settingsLoaded: settings !== null, - preflightStatusChecked, - linearStatusChecked, + // Why: task-source readiness is a capability group. Once any provider is + // usable, unrelated stale provider checks should not hide setup progress. + preflightStatusChecked: !taskSourceStatus.checking, + linearStatusChecked: true, + jiraStatusChecked: true, browserUseSkillDiscoveryLoading: detectedBrowserUseSkillLoading, computerUseSkillDiscoveryLoading: computerUseSkillLoading, orchestrationSkillDiscoveryLoading: detectedOrchestrationSkillLoading, diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index 5c8b50ade67..8071ae40f86 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -15,6 +15,7 @@ import { } from '../../../../shared/task-providers' import { useActivityUnreadCount } from '@/components/activity/useActivityUnreadCount' import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { useMobileSidebarOnboardingBadge } from './mobile-sidebar-onboarding-badge' import { ContextMenu, @@ -108,7 +109,11 @@ const SidebarNav = React.memo(function SidebarNav() { const defaultTaskSource = useAppStore((s) => s.settings?.defaultTaskSource ?? 'github') const preflightStatus = useAppStore((s) => s.preflightStatus) const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) + const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey) const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + const expectedPreflightContextKey = useAppStore((s) => + localPreflightContextKey(getLocalPreflightContext(s)) + ) const linearStatus = useAppStore((s) => s.linearStatus) const linearStatusChecked = useAppStore((s) => s.linearStatusChecked) const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) @@ -119,12 +124,13 @@ const SidebarNav = React.memo(function SidebarNav() { () => normalizeVisibleTaskProviders(rawVisibleTaskProviders), [rawVisibleTaskProviders] ) + const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey const visibleTaskProviders = React.useMemo( () => restoreAvailableDefaultTaskProvider( preferredVisibleTaskProviders, { - gitlabInstalled: preflightStatus?.glab?.installed === true, + gitlabInstalled: preflightStatusCurrent && preflightStatus?.glab?.installed === true, linearConnected: linearStatus.connected === true }, defaultTaskSource @@ -133,6 +139,7 @@ const SidebarNav = React.memo(function SidebarNav() { defaultTaskSource, linearStatus.connected, preferredVisibleTaskProviders, + preflightStatusCurrent, preflightStatus?.glab?.installed ] ) @@ -142,13 +149,19 @@ const SidebarNav = React.memo(function SidebarNav() { ) React.useEffect(() => { - if (!preflightStatusChecked) { + if (!preflightStatusChecked || !preflightStatusCurrent) { void refreshPreflightStatus() } if (!linearStatusChecked) { void checkLinearConnection() } - }, [checkLinearConnection, linearStatusChecked, preflightStatusChecked, refreshPreflightStatus]) + }, [ + checkLinearConnection, + linearStatusChecked, + preflightStatusChecked, + preflightStatusCurrent, + refreshPreflightStatus + ]) // Why: warm the GitHub work-item cache on hover/focus so by the time the // user's click finishes the round-trip has either completed or is already diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c56d03813bd..364462bc46e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -232,6 +232,12 @@ "ui": { "66e3bd7ce6": "Sent to {{value0}}", "53883b7bc3": "Couldn't send to {{value0}}" + }, + "jira": { + "856083302c": "Jira connection was superseded by a newer request." + }, + "linear": { + "37d36984d0": "Linear connection was superseded by a newer request." } } }, @@ -4657,7 +4663,11 @@ "9707523939": "Pull requests and build statuses", "a565377c38": "not-installed", "15cf990798": "Not authenticated", - "f7eb5f0b24": "Not installed" + "f7eb5f0b24": "Not installed", + "3ba07f933b": "Connect issue trackers Orca can use to browse tasks and start workspaces with linked context.", + "70e885705b": "Task providers", + "1683acbac4": "Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.", + "298c65ecac": "Review providers" }, "KagiSessionLinkForm": { "92f0b4e472": "Clear", @@ -7401,6 +7411,103 @@ "agent-generated-tab-title-copy": { "19ad21615a": "Auto-generate tab titles", "b036c7a409": "Derive short stable tab names from the first known agent prompt. Manual renames always win." + }, + "cli": { + "source": { + "control": { + "integration": { + "cards": { + "d5b3be8ecd": "Re-check", + "8cbc39f862": "Learn more", + "707180d09c": "glab auth login", + "4be0616873": "The GitLab CLI is installed but not authenticated. Run this command in a terminal:", + "54a640af7a": "Install GitLab CLI", + "b56fd5676a": "Install the GitLab CLI to enable merge requests, issues, and pipelines.", + "faddeb763d": "GitLab CLI status is not available in this runtime yet.", + "a47f71e357": "CLI.", + "2a6b359e75": "glab", + "1f2b347bd3": "Merge requests, issues, todos, and pipelines via the", + "8d90249d22": "gh auth login", + "2e44dda68a": "The GitHub CLI is installed but not authenticated. Run this command in a terminal:", + "7755c28af5": "Install GitHub CLI", + "23cb5a0dee": "Install the GitHub CLI to enable pull requests, issues, and checks.", + "6f30fc4216": "GitHub CLI status is not available in this runtime yet.", + "6b2cfb52b4": "gh", + "b4d900e7f1": "Pull requests, issues, and checks via the" + } + } + } + } + }, + "task": { + "tracker": { + "integration": { + "cards": { + "c90f2ef419": "Re-check", + "dd3529015d": "Disconnect {{value0}}", + "8b2408a8e5": "Jira is connected for this runtime. Re-check if the connected site list looks stale.", + "8c20e76308": "Each connected Jira site has one token stored by the active runtime.", + "c24e56c532": "Test", + "3e7c10d286": "Testing...", + "a2c0015fb8": "Verified", + "e2ff968276": "Connect Jira", + "60996beda6": "Add Jira site", + "7ca5ffffdb": "Browse, create, and start work from Jira Cloud issues.", + "a1093a06c7": "Checking Jira access before showing setup actions.", + "9fa04a032e": "{{value0}} site{{value1}} connected", + "cef18762a2": "Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.", + "6224fe9d34": "Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.", + "1a12e33fe5": "Add Linear access", + "622c224082": "Add workspace access", + "eae4a9f16b": "Add Linear access to browse and link issues.", + "fe9231215b": "Checking Linear access before showing setup actions.", + "e1f5e6424c": "{{value0}} workspace{{value1}} connected", + "disconnect_all": "Disconnect" + } + } + } + }, + "token": { + "source": { + "control": { + "integration": { + "cards": { + "793a06e899": "Re-check", + "1a9475dace": "Learn more", + "19fb419c12": "Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", + "60708f23da": "only when Orca cannot derive the API URL from the remote.", + "709057ad91": "ORCA_GITEA_API_BASE_URL", + "6da9dfa5de": "for private repositories, and set", + "6d5c2a3005": "ORCA_GITEA_TOKEN", + "fcbe0469fd": "Public repositories are detected from their git remote. Set", + "0613928cb3": "Gitea status is not available in this runtime yet.", + "05863d2599": "Pull requests and commit statuses via the Gitea REST API.", + "52f75876be": "Pull requests and commit statuses for detected repositories", + "0b5242f8a2": "{{value0}} · Pull requests and commit statuses", + "40f678df73": "Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", + "7bd345e3f6": "only when Orca cannot derive the API base URL from the git remote.", + "186a6689df": "ORCA_AZURE_DEVOPS_API_BASE_URL", + "b8a10b07c1": ". Set", + "fbfd237f5e": "ORCA_AZURE_DEVOPS_ACCESS_TOKEN", + "087feb92f1": ", or set", + "48842720d2": "ORCA_AZURE_DEVOPS_TOKEN", + "7bbc9c64f0": "Set", + "f3f47dc7de": "Azure DevOps status is not available in this runtime yet.", + "0eb50d5593": "Pull requests and build statuses via Azure DevOps REST API tokens.", + "54636c65d4": "Pull requests and build statuses for detected Azure Repos", + "ea204f5e03": "{{value0}} · Pull requests and build statuses", + "6154b02093": "Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.", + "e63fe8f627": "ORCA_BITBUCKET_ACCESS_TOKEN", + "19416c874c": "ORCA_BITBUCKET_API_TOKEN", + "fc71a0e7aa": "and", + "63a7f47392": "ORCA_BITBUCKET_EMAIL", + "24ac1c69dc": "Bitbucket status is not available in this runtime yet.", + "a924e8dcd1": "Pull requests and build statuses via Bitbucket Cloud API tokens.", + "0fa5629dad": "Pull requests and build statuses" + } + } + } + } } }, "right": { @@ -8959,6 +9066,30 @@ "b7345c18db": "An unexpected error occurred.", "5f97caf76b": "Installing…", "c2df599513": "Install CLI & Skill" + }, + "ConnectIntegrationsList": { + "3dddb2d565": "connected for tasks", + "33b650af52": "Connect where your team tracks work. Orca starts workspaces with the issue title, link, and context already attached.", + "5b3577a492": "connected for review status", + "3a1fcdddad": "Two quick steps: connect where your code is reviewed, then where your team plans work.", + "list_end": ", and ", + "list_pair": " and ", + "list_mid": ", ", + "code_host_tasks_summary": "issues available as tasks · add Linear or Jira if your team plans work there", + "code_host_tasks_caption": "Your code host's issues also work as tasks.", + "review_step_title": "See PR status while agents work", + "review_step_description": "Connect a review provider so Orca can show PR or MR status, checks, and reviews.", + "task_step_title": "Start agents on your tasks without leaving Orca" + }, + "connect": { + "integration": { + "step": { + "0f47ff17c6": "Change", + "5538eb6743": "Done", + "open_step": "Open", + "close_step": "Close" + } + } } }, "tips": { @@ -10163,6 +10294,25 @@ "8490e5d36a": "Confirm", "56f5c60e0c": "Cancel" } + }, + "jira": { + "connect": { + "dialog": { + "63ce735809": "Connect", + "4a2ab52781": "Verifying…", + "79e7aaed39": "Cancel", + "fdd26d81cc": "Atlassian account settings", + "8090504a3e": "Create a token in", + "7b3967c12f": "Atlassian API token", + "3d81bf3ab3": "API token", + "e91b9a4073": "you@example.com", + "2849ddb295": "Atlassian email", + "70fcd360c4": "https://example.atlassian.net", + "e176f9d0c5": "Jira Cloud site URL", + "d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.", + "8388bdea2b": "Connect Jira site" + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cd442f403e2..2f41ecd5eca 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -232,6 +232,12 @@ "ui": { "66e3bd7ce6": "Enviado a {{value0}}", "53883b7bc3": "No se pudo enviar a {{value0}}" + }, + "jira": { + "856083302c": "Jira connection was superseded by a newer request." + }, + "linear": { + "37d36984d0": "Linear connection was superseded by a newer request." } } }, @@ -4544,7 +4550,11 @@ "9707523939": "Solicitudes de extracción y estados de compilación", "a565377c38": "no instalado", "15cf990798": "No autenticado", - "f7eb5f0b24": "No instalado" + "f7eb5f0b24": "No instalado", + "3ba07f933b": "Connect issue trackers Orca can use to browse tasks and start workspaces with linked context.", + "70e885705b": "Task providers", + "1683acbac4": "Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.", + "298c65ecac": "Review providers" }, "KagiSessionLinkForm": { "92f0b4e472": "Claro", @@ -7274,6 +7284,103 @@ }, "YamlThemeImportButton": { "label": "Import from YAML" + }, + "cli": { + "source": { + "control": { + "integration": { + "cards": { + "d5b3be8ecd": "Re-check", + "8cbc39f862": "Learn more", + "707180d09c": "glab auth login", + "4be0616873": "The GitLab CLI is installed but not authenticated. Run this command in a terminal:", + "54a640af7a": "Install GitLab CLI", + "b56fd5676a": "Install the GitLab CLI to enable merge requests, issues, and pipelines.", + "faddeb763d": "GitLab CLI status is not available in this runtime yet.", + "a47f71e357": "CLI.", + "2a6b359e75": "glab", + "1f2b347bd3": "Merge requests, issues, todos, and pipelines via the", + "8d90249d22": "gh auth login", + "2e44dda68a": "The GitHub CLI is installed but not authenticated. Run this command in a terminal:", + "7755c28af5": "Install GitHub CLI", + "23cb5a0dee": "Install the GitHub CLI to enable pull requests, issues, and checks.", + "6f30fc4216": "GitHub CLI status is not available in this runtime yet.", + "6b2cfb52b4": "gh", + "b4d900e7f1": "Pull requests, issues, and checks via the" + } + } + } + } + }, + "task": { + "tracker": { + "integration": { + "cards": { + "c90f2ef419": "Re-check", + "dd3529015d": "Disconnect {{value0}}", + "8b2408a8e5": "Jira is connected for this runtime. Re-check if the connected site list looks stale.", + "8c20e76308": "Each connected Jira site has one token stored by the active runtime.", + "c24e56c532": "Test", + "3e7c10d286": "Testing...", + "a2c0015fb8": "Verified", + "e2ff968276": "Connect Jira", + "60996beda6": "Add Jira site", + "7ca5ffffdb": "Browse, create, and start work from Jira Cloud issues.", + "a1093a06c7": "Checking Jira access before showing setup actions.", + "9fa04a032e": "{{value0}} site{{value1}} connected", + "cef18762a2": "Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.", + "6224fe9d34": "Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.", + "1a12e33fe5": "Add Linear access", + "622c224082": "Add workspace access", + "eae4a9f16b": "Add Linear access to browse and link issues.", + "fe9231215b": "Checking Linear access before showing setup actions.", + "e1f5e6424c": "{{value0}} workspace{{value1}} connected", + "disconnect_all": "Disconnect" + } + } + } + }, + "token": { + "source": { + "control": { + "integration": { + "cards": { + "793a06e899": "Re-check", + "1a9475dace": "Learn more", + "19fb419c12": "Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", + "60708f23da": "only when Orca cannot derive the API URL from the remote.", + "709057ad91": "ORCA_GITEA_API_BASE_URL", + "6da9dfa5de": "for private repositories, and set", + "6d5c2a3005": "ORCA_GITEA_TOKEN", + "fcbe0469fd": "Public repositories are detected from their git remote. Set", + "0613928cb3": "Gitea status is not available in this runtime yet.", + "05863d2599": "Pull requests and commit statuses via the Gitea REST API.", + "52f75876be": "Pull requests and commit statuses for detected repositories", + "0b5242f8a2": "{{value0}} · Pull requests and commit statuses", + "40f678df73": "Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", + "7bd345e3f6": "only when Orca cannot derive the API base URL from the git remote.", + "186a6689df": "ORCA_AZURE_DEVOPS_API_BASE_URL", + "b8a10b07c1": ". Set", + "fbfd237f5e": "ORCA_AZURE_DEVOPS_ACCESS_TOKEN", + "087feb92f1": ", or set", + "48842720d2": "ORCA_AZURE_DEVOPS_TOKEN", + "7bbc9c64f0": "Set", + "f3f47dc7de": "Azure DevOps status is not available in this runtime yet.", + "0eb50d5593": "Pull requests and build statuses via Azure DevOps REST API tokens.", + "54636c65d4": "Pull requests and build statuses for detected Azure Repos", + "ea204f5e03": "{{value0}} · Pull requests and build statuses", + "6154b02093": "Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.", + "e63fe8f627": "ORCA_BITBUCKET_ACCESS_TOKEN", + "19416c874c": "ORCA_BITBUCKET_API_TOKEN", + "fc71a0e7aa": "and", + "63a7f47392": "ORCA_BITBUCKET_EMAIL", + "24ac1c69dc": "Bitbucket status is not available in this runtime yet.", + "a924e8dcd1": "Pull requests and build statuses via Bitbucket Cloud API tokens.", + "0fa5629dad": "Pull requests and build statuses" + } + } + } + } } }, "right": { @@ -8828,6 +8935,30 @@ "b7345c18db": "Se produjo un error inesperado.", "5f97caf76b": "Instalando…", "c2df599513": "Instalar CLI y habilidad" + }, + "ConnectIntegrationsList": { + "3dddb2d565": "connected for tasks", + "33b650af52": "Connect where your team tracks work. Orca starts workspaces with the issue title, link, and context already attached.", + "5b3577a492": "connected for review status", + "3a1fcdddad": "Two quick steps: connect where your code is reviewed, then where your team plans work.", + "list_end": ", and ", + "list_pair": " and ", + "list_mid": ", ", + "code_host_tasks_summary": "issues available as tasks · add Linear or Jira if your team plans work there", + "code_host_tasks_caption": "Your code host's issues also work as tasks.", + "review_step_title": "See PR status while agents work", + "review_step_description": "Connect a review provider so Orca can show PR or MR status, checks, and reviews.", + "task_step_title": "Start agents on your tasks without leaving Orca" + }, + "connect": { + "integration": { + "step": { + "0f47ff17c6": "Change", + "5538eb6743": "Done", + "open_step": "Open", + "close_step": "Close" + } + } } }, "tips": { @@ -10032,6 +10163,25 @@ "8490e5d36a": "Confirmar", "56f5c60e0c": "Cancelar" } + }, + "jira": { + "connect": { + "dialog": { + "63ce735809": "Connect", + "4a2ab52781": "Verifying…", + "79e7aaed39": "Cancel", + "fdd26d81cc": "Atlassian account settings", + "8090504a3e": "Create a token in", + "7b3967c12f": "Atlassian API token", + "3d81bf3ab3": "API token", + "e91b9a4073": "you@example.com", + "2849ddb295": "Atlassian email", + "70fcd360c4": "https://example.atlassian.net", + "e176f9d0c5": "Jira Cloud site URL", + "d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.", + "8388bdea2b": "Connect Jira site" + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 57429146b9c..229becbc2d6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -232,6 +232,12 @@ "ui": { "66e3bd7ce6": "{{value0}} に送信されました", "53883b7bc3": "{{value0}} に送信できませんでした" + }, + "jira": { + "856083302c": "Jira connection was superseded by a newer request." + }, + "linear": { + "37d36984d0": "Linear connection was superseded by a newer request." } } }, @@ -4637,7 +4643,11 @@ "9707523939": "PR とビルドステータス", "a565377c38": "未インストール", "15cf990798": "認証されていません", - "f7eb5f0b24": "未インストール" + "f7eb5f0b24": "未インストール", + "3ba07f933b": "Connect issue trackers Orca can use to browse tasks and start workspaces with linked context.", + "70e885705b": "Task providers", + "1683acbac4": "Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.", + "298c65ecac": "Review providers" }, "KagiSessionLinkForm": { "92f0b4e472": "クリア", @@ -7382,6 +7392,103 @@ } } } + }, + "cli": { + "source": { + "control": { + "integration": { + "cards": { + "d5b3be8ecd": "Re-check", + "8cbc39f862": "Learn more", + "707180d09c": "glab auth login", + "4be0616873": "The GitLab CLI is installed but not authenticated. Run this command in a terminal:", + "54a640af7a": "Install GitLab CLI", + "b56fd5676a": "Install the GitLab CLI to enable merge requests, issues, and pipelines.", + "faddeb763d": "GitLab CLI status is not available in this runtime yet.", + "a47f71e357": "CLI.", + "2a6b359e75": "glab", + "1f2b347bd3": "Merge requests, issues, todos, and pipelines via the", + "8d90249d22": "gh auth login", + "2e44dda68a": "The GitHub CLI is installed but not authenticated. Run this command in a terminal:", + "7755c28af5": "Install GitHub CLI", + "23cb5a0dee": "Install the GitHub CLI to enable pull requests, issues, and checks.", + "6f30fc4216": "GitHub CLI status is not available in this runtime yet.", + "6b2cfb52b4": "gh", + "b4d900e7f1": "Pull requests, issues, and checks via the" + } + } + } + } + }, + "task": { + "tracker": { + "integration": { + "cards": { + "c90f2ef419": "Re-check", + "dd3529015d": "Disconnect {{value0}}", + "8b2408a8e5": "Jira is connected for this runtime. Re-check if the connected site list looks stale.", + "8c20e76308": "Each connected Jira site has one token stored by the active runtime.", + "c24e56c532": "Test", + "3e7c10d286": "Testing...", + "a2c0015fb8": "Verified", + "e2ff968276": "Connect Jira", + "60996beda6": "Add Jira site", + "7ca5ffffdb": "Browse, create, and start work from Jira Cloud issues.", + "a1093a06c7": "Checking Jira access before showing setup actions.", + "9fa04a032e": "{{value0}} site{{value1}} connected", + "cef18762a2": "Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.", + "6224fe9d34": "Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.", + "1a12e33fe5": "Add Linear access", + "622c224082": "Add workspace access", + "eae4a9f16b": "Add Linear access to browse and link issues.", + "fe9231215b": "Checking Linear access before showing setup actions.", + "e1f5e6424c": "{{value0}} workspace{{value1}} connected", + "disconnect_all": "Disconnect" + } + } + } + }, + "token": { + "source": { + "control": { + "integration": { + "cards": { + "793a06e899": "Re-check", + "1a9475dace": "Learn more", + "19fb419c12": "Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", + "60708f23da": "only when Orca cannot derive the API URL from the remote.", + "709057ad91": "ORCA_GITEA_API_BASE_URL", + "6da9dfa5de": "for private repositories, and set", + "6d5c2a3005": "ORCA_GITEA_TOKEN", + "fcbe0469fd": "Public repositories are detected from their git remote. Set", + "0613928cb3": "Gitea status is not available in this runtime yet.", + "05863d2599": "Pull requests and commit statuses via the Gitea REST API.", + "52f75876be": "Pull requests and commit statuses for detected repositories", + "0b5242f8a2": "{{value0}} · Pull requests and commit statuses", + "40f678df73": "Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", + "7bd345e3f6": "only when Orca cannot derive the API base URL from the git remote.", + "186a6689df": "ORCA_AZURE_DEVOPS_API_BASE_URL", + "b8a10b07c1": ". Set", + "fbfd237f5e": "ORCA_AZURE_DEVOPS_ACCESS_TOKEN", + "087feb92f1": ", or set", + "48842720d2": "ORCA_AZURE_DEVOPS_TOKEN", + "7bbc9c64f0": "Set", + "f3f47dc7de": "Azure DevOps status is not available in this runtime yet.", + "0eb50d5593": "Pull requests and build statuses via Azure DevOps REST API tokens.", + "54636c65d4": "Pull requests and build statuses for detected Azure Repos", + "ea204f5e03": "{{value0}} · Pull requests and build statuses", + "6154b02093": "Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.", + "e63fe8f627": "ORCA_BITBUCKET_ACCESS_TOKEN", + "19416c874c": "ORCA_BITBUCKET_API_TOKEN", + "fc71a0e7aa": "and", + "63a7f47392": "ORCA_BITBUCKET_EMAIL", + "24ac1c69dc": "Bitbucket status is not available in this runtime yet.", + "a924e8dcd1": "Pull requests and build statuses via Bitbucket Cloud API tokens.", + "0fa5629dad": "Pull requests and build statuses" + } + } + } + } } }, "right": { @@ -8940,6 +9047,30 @@ "b7345c18db": "予期しないエラーが発生しました。", "5f97caf76b": "インストール中…", "c2df599513": "CLI とスキルをインストール" + }, + "ConnectIntegrationsList": { + "3dddb2d565": "connected for tasks", + "33b650af52": "Connect where your team tracks work. Orca starts workspaces with the issue title, link, and context already attached.", + "5b3577a492": "connected for review status", + "3a1fcdddad": "Two quick steps: connect where your code is reviewed, then where your team plans work.", + "list_end": ", and ", + "list_pair": " and ", + "list_mid": ", ", + "code_host_tasks_summary": "issues available as tasks · add Linear or Jira if your team plans work there", + "code_host_tasks_caption": "Your code host's issues also work as tasks.", + "review_step_title": "See PR status while agents work", + "review_step_description": "Connect a review provider so Orca can show PR or MR status, checks, and reviews.", + "task_step_title": "Start agents on your tasks without leaving Orca" + }, + "connect": { + "integration": { + "step": { + "0f47ff17c6": "Change", + "5538eb6743": "Done", + "open_step": "Open", + "close_step": "Close" + } + } } }, "tips": { @@ -10144,6 +10275,25 @@ "8490e5d36a": "確認する", "56f5c60e0c": "キャンセル" } + }, + "jira": { + "connect": { + "dialog": { + "63ce735809": "Connect", + "4a2ab52781": "Verifying…", + "79e7aaed39": "Cancel", + "fdd26d81cc": "Atlassian account settings", + "8090504a3e": "Create a token in", + "7b3967c12f": "Atlassian API token", + "3d81bf3ab3": "API token", + "e91b9a4073": "you@example.com", + "2849ddb295": "Atlassian email", + "70fcd360c4": "https://example.atlassian.net", + "e176f9d0c5": "Jira Cloud site URL", + "d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.", + "8388bdea2b": "Connect Jira site" + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b1051bfb580..ad75e066cd7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -232,6 +232,12 @@ "ui": { "66e3bd7ce6": "{{value0}}(으)로 보냄", "53883b7bc3": "{{value0}}(으)로 보낼 수 없습니다." + }, + "jira": { + "856083302c": "Jira connection was superseded by a newer request." + }, + "linear": { + "37d36984d0": "Linear connection was superseded by a newer request." } } }, @@ -4601,7 +4607,11 @@ "9707523939": "PR 및 빌드 상태", "a565377c38": "설치되지 않은", "15cf990798": "인증되지 않음", - "f7eb5f0b24": "설치되지 않음" + "f7eb5f0b24": "설치되지 않음", + "3ba07f933b": "Connect issue trackers Orca can use to browse tasks and start workspaces with linked context.", + "70e885705b": "Task providers", + "1683acbac4": "Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.", + "298c65ecac": "Review providers" }, "KagiSessionLinkForm": { "92f0b4e472": "지우기", @@ -7382,6 +7392,103 @@ }, "YamlThemeImportButton": { "label": "Import from YAML" + }, + "cli": { + "source": { + "control": { + "integration": { + "cards": { + "d5b3be8ecd": "Re-check", + "8cbc39f862": "Learn more", + "707180d09c": "glab auth login", + "4be0616873": "The GitLab CLI is installed but not authenticated. Run this command in a terminal:", + "54a640af7a": "Install GitLab CLI", + "b56fd5676a": "Install the GitLab CLI to enable merge requests, issues, and pipelines.", + "faddeb763d": "GitLab CLI status is not available in this runtime yet.", + "a47f71e357": "CLI.", + "2a6b359e75": "glab", + "1f2b347bd3": "Merge requests, issues, todos, and pipelines via the", + "8d90249d22": "gh auth login", + "2e44dda68a": "The GitHub CLI is installed but not authenticated. Run this command in a terminal:", + "7755c28af5": "Install GitHub CLI", + "23cb5a0dee": "Install the GitHub CLI to enable pull requests, issues, and checks.", + "6f30fc4216": "GitHub CLI status is not available in this runtime yet.", + "6b2cfb52b4": "gh", + "b4d900e7f1": "Pull requests, issues, and checks via the" + } + } + } + } + }, + "task": { + "tracker": { + "integration": { + "cards": { + "c90f2ef419": "Re-check", + "dd3529015d": "Disconnect {{value0}}", + "8b2408a8e5": "Jira is connected for this runtime. Re-check if the connected site list looks stale.", + "8c20e76308": "Each connected Jira site has one token stored by the active runtime.", + "c24e56c532": "Test", + "3e7c10d286": "Testing...", + "a2c0015fb8": "Verified", + "e2ff968276": "Connect Jira", + "60996beda6": "Add Jira site", + "7ca5ffffdb": "Browse, create, and start work from Jira Cloud issues.", + "a1093a06c7": "Checking Jira access before showing setup actions.", + "9fa04a032e": "{{value0}} site{{value1}} connected", + "cef18762a2": "Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.", + "6224fe9d34": "Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.", + "1a12e33fe5": "Add Linear access", + "622c224082": "Add workspace access", + "eae4a9f16b": "Add Linear access to browse and link issues.", + "fe9231215b": "Checking Linear access before showing setup actions.", + "e1f5e6424c": "{{value0}} workspace{{value1}} connected", + "disconnect_all": "Disconnect" + } + } + } + }, + "token": { + "source": { + "control": { + "integration": { + "cards": { + "793a06e899": "Re-check", + "1a9475dace": "Learn more", + "19fb419c12": "Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", + "60708f23da": "only when Orca cannot derive the API URL from the remote.", + "709057ad91": "ORCA_GITEA_API_BASE_URL", + "6da9dfa5de": "for private repositories, and set", + "6d5c2a3005": "ORCA_GITEA_TOKEN", + "fcbe0469fd": "Public repositories are detected from their git remote. Set", + "0613928cb3": "Gitea status is not available in this runtime yet.", + "05863d2599": "Pull requests and commit statuses via the Gitea REST API.", + "52f75876be": "Pull requests and commit statuses for detected repositories", + "0b5242f8a2": "{{value0}} · Pull requests and commit statuses", + "40f678df73": "Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", + "7bd345e3f6": "only when Orca cannot derive the API base URL from the git remote.", + "186a6689df": "ORCA_AZURE_DEVOPS_API_BASE_URL", + "b8a10b07c1": ". Set", + "fbfd237f5e": "ORCA_AZURE_DEVOPS_ACCESS_TOKEN", + "087feb92f1": ", or set", + "48842720d2": "ORCA_AZURE_DEVOPS_TOKEN", + "7bbc9c64f0": "Set", + "f3f47dc7de": "Azure DevOps status is not available in this runtime yet.", + "0eb50d5593": "Pull requests and build statuses via Azure DevOps REST API tokens.", + "54636c65d4": "Pull requests and build statuses for detected Azure Repos", + "ea204f5e03": "{{value0}} · Pull requests and build statuses", + "6154b02093": "Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.", + "e63fe8f627": "ORCA_BITBUCKET_ACCESS_TOKEN", + "19416c874c": "ORCA_BITBUCKET_API_TOKEN", + "fc71a0e7aa": "and", + "63a7f47392": "ORCA_BITBUCKET_EMAIL", + "24ac1c69dc": "Bitbucket status is not available in this runtime yet.", + "a924e8dcd1": "Pull requests and build statuses via Bitbucket Cloud API tokens.", + "0fa5629dad": "Pull requests and build statuses" + } + } + } + } } }, "right": { @@ -8940,6 +9047,30 @@ "b7345c18db": "예기치 않은 오류가 발생했습니다.", "5f97caf76b": "설치 중…", "c2df599513": "CLI 및 스킬 설치" + }, + "ConnectIntegrationsList": { + "3dddb2d565": "connected for tasks", + "33b650af52": "Connect where your team tracks work. Orca starts workspaces with the issue title, link, and context already attached.", + "5b3577a492": "connected for review status", + "3a1fcdddad": "Two quick steps: connect where your code is reviewed, then where your team plans work.", + "list_end": ", and ", + "list_pair": " and ", + "list_mid": ", ", + "code_host_tasks_summary": "issues available as tasks · add Linear or Jira if your team plans work there", + "code_host_tasks_caption": "Your code host's issues also work as tasks.", + "review_step_title": "See PR status while agents work", + "review_step_description": "Connect a review provider so Orca can show PR or MR status, checks, and reviews.", + "task_step_title": "Start agents on your tasks without leaving Orca" + }, + "connect": { + "integration": { + "step": { + "0f47ff17c6": "Change", + "5538eb6743": "Done", + "open_step": "Open", + "close_step": "Close" + } + } } }, "tips": { @@ -10144,6 +10275,25 @@ "8490e5d36a": "확인", "56f5c60e0c": "취소" } + }, + "jira": { + "connect": { + "dialog": { + "63ce735809": "Connect", + "4a2ab52781": "Verifying…", + "79e7aaed39": "Cancel", + "fdd26d81cc": "Atlassian account settings", + "8090504a3e": "Create a token in", + "7b3967c12f": "Atlassian API token", + "3d81bf3ab3": "API token", + "e91b9a4073": "you@example.com", + "2849ddb295": "Atlassian email", + "70fcd360c4": "https://example.atlassian.net", + "e176f9d0c5": "Jira Cloud site URL", + "d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.", + "8388bdea2b": "Connect Jira site" + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 67a04e57f24..3a78d426f24 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -232,6 +232,12 @@ "ui": { "66e3bd7ce6": "发送至 {{value0}}", "53883b7bc3": "无法发送至 {{value0}}" + }, + "jira": { + "856083302c": "Jira connection was superseded by a newer request." + }, + "linear": { + "37d36984d0": "Linear connection was superseded by a newer request." } } }, @@ -4601,7 +4607,11 @@ "9707523939": "拉取请求和构建状态", "a565377c38": "未安装", "15cf990798": "未经过验证", - "f7eb5f0b24": "未安装" + "f7eb5f0b24": "未安装", + "3ba07f933b": "Connect issue trackers Orca can use to browse tasks and start workspaces with linked context.", + "70e885705b": "Task providers", + "1683acbac4": "Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.", + "298c65ecac": "Review providers" }, "KagiSessionLinkForm": { "92f0b4e472": "清除", @@ -7382,6 +7392,103 @@ }, "YamlThemeImportButton": { "label": "Import from YAML" + }, + "cli": { + "source": { + "control": { + "integration": { + "cards": { + "d5b3be8ecd": "Re-check", + "8cbc39f862": "Learn more", + "707180d09c": "glab auth login", + "4be0616873": "The GitLab CLI is installed but not authenticated. Run this command in a terminal:", + "54a640af7a": "Install GitLab CLI", + "b56fd5676a": "Install the GitLab CLI to enable merge requests, issues, and pipelines.", + "faddeb763d": "GitLab CLI status is not available in this runtime yet.", + "a47f71e357": "CLI.", + "2a6b359e75": "glab", + "1f2b347bd3": "Merge requests, issues, todos, and pipelines via the", + "8d90249d22": "gh auth login", + "2e44dda68a": "The GitHub CLI is installed but not authenticated. Run this command in a terminal:", + "7755c28af5": "Install GitHub CLI", + "23cb5a0dee": "Install the GitHub CLI to enable pull requests, issues, and checks.", + "6f30fc4216": "GitHub CLI status is not available in this runtime yet.", + "6b2cfb52b4": "gh", + "b4d900e7f1": "Pull requests, issues, and checks via the" + } + } + } + } + }, + "task": { + "tracker": { + "integration": { + "cards": { + "c90f2ef419": "Re-check", + "dd3529015d": "Disconnect {{value0}}", + "8b2408a8e5": "Jira is connected for this runtime. Re-check if the connected site list looks stale.", + "8c20e76308": "Each connected Jira site has one token stored by the active runtime.", + "c24e56c532": "Test", + "3e7c10d286": "Testing...", + "a2c0015fb8": "Verified", + "e2ff968276": "Connect Jira", + "60996beda6": "Add Jira site", + "7ca5ffffdb": "Browse, create, and start work from Jira Cloud issues.", + "a1093a06c7": "Checking Jira access before showing setup actions.", + "9fa04a032e": "{{value0}} site{{value1}} connected", + "cef18762a2": "Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.", + "6224fe9d34": "Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.", + "1a12e33fe5": "Add Linear access", + "622c224082": "Add workspace access", + "eae4a9f16b": "Add Linear access to browse and link issues.", + "fe9231215b": "Checking Linear access before showing setup actions.", + "e1f5e6424c": "{{value0}} workspace{{value1}} connected", + "disconnect_all": "Disconnect" + } + } + } + }, + "token": { + "source": { + "control": { + "integration": { + "cards": { + "793a06e899": "Re-check", + "1a9475dace": "Learn more", + "19fb419c12": "Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", + "60708f23da": "only when Orca cannot derive the API URL from the remote.", + "709057ad91": "ORCA_GITEA_API_BASE_URL", + "6da9dfa5de": "for private repositories, and set", + "6d5c2a3005": "ORCA_GITEA_TOKEN", + "fcbe0469fd": "Public repositories are detected from their git remote. Set", + "0613928cb3": "Gitea status is not available in this runtime yet.", + "05863d2599": "Pull requests and commit statuses via the Gitea REST API.", + "52f75876be": "Pull requests and commit statuses for detected repositories", + "0b5242f8a2": "{{value0}} · Pull requests and commit statuses", + "40f678df73": "Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", + "7bd345e3f6": "only when Orca cannot derive the API base URL from the git remote.", + "186a6689df": "ORCA_AZURE_DEVOPS_API_BASE_URL", + "b8a10b07c1": ". Set", + "fbfd237f5e": "ORCA_AZURE_DEVOPS_ACCESS_TOKEN", + "087feb92f1": ", or set", + "48842720d2": "ORCA_AZURE_DEVOPS_TOKEN", + "7bbc9c64f0": "Set", + "f3f47dc7de": "Azure DevOps status is not available in this runtime yet.", + "0eb50d5593": "Pull requests and build statuses via Azure DevOps REST API tokens.", + "54636c65d4": "Pull requests and build statuses for detected Azure Repos", + "ea204f5e03": "{{value0}} · Pull requests and build statuses", + "6154b02093": "Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.", + "e63fe8f627": "ORCA_BITBUCKET_ACCESS_TOKEN", + "19416c874c": "ORCA_BITBUCKET_API_TOKEN", + "fc71a0e7aa": "and", + "63a7f47392": "ORCA_BITBUCKET_EMAIL", + "24ac1c69dc": "Bitbucket status is not available in this runtime yet.", + "a924e8dcd1": "Pull requests and build statuses via Bitbucket Cloud API tokens.", + "0fa5629dad": "Pull requests and build statuses" + } + } + } + } } }, "right": { @@ -8940,6 +9047,30 @@ "b7345c18db": "发生了意外错误。", "5f97caf76b": "正在安装…", "c2df599513": "安装 CLI 和技能" + }, + "ConnectIntegrationsList": { + "3dddb2d565": "connected for tasks", + "33b650af52": "Connect where your team tracks work. Orca starts workspaces with the issue title, link, and context already attached.", + "5b3577a492": "connected for review status", + "3a1fcdddad": "Two quick steps: connect where your code is reviewed, then where your team plans work.", + "list_end": ", and ", + "list_pair": " and ", + "list_mid": ", ", + "code_host_tasks_summary": "issues available as tasks · add Linear or Jira if your team plans work there", + "code_host_tasks_caption": "Your code host's issues also work as tasks.", + "review_step_title": "See PR status while agents work", + "review_step_description": "Connect a review provider so Orca can show PR or MR status, checks, and reviews.", + "task_step_title": "Start agents on your tasks without leaving Orca" + }, + "connect": { + "integration": { + "step": { + "0f47ff17c6": "Change", + "5538eb6743": "Done", + "open_step": "Open", + "close_step": "Close" + } + } } }, "tips": { @@ -10144,6 +10275,25 @@ "8490e5d36a": "确认", "56f5c60e0c": "取消" } + }, + "jira": { + "connect": { + "dialog": { + "63ce735809": "Connect", + "4a2ab52781": "Verifying…", + "79e7aaed39": "Cancel", + "fdd26d81cc": "Atlassian account settings", + "8090504a3e": "Create a token in", + "7b3967c12f": "Atlassian API token", + "3d81bf3ab3": "API token", + "e91b9a4073": "you@example.com", + "2849ddb295": "Atlassian email", + "70fcd360c4": "https://example.atlassian.net", + "e176f9d0c5": "Jira Cloud site URL", + "d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.", + "8388bdea2b": "Connect Jira site" + } + } } }, "i18n": { diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index 67ebdbcab55..e53f22365d1 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -88,6 +88,21 @@ describe('local preflight context', () => { expect(localPreflightContextKey(getLocalPreflightContext(state))).toBe('host') }) + it('keys preflight by active runtime before local WSL or host context', () => { + const state = { + ...makeState({ + repoPath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo` + }), + settings: { + activeRuntimeEnvironmentId: 'runtime-1' + } + } as AppState + + const context = getLocalPreflightContext(state) + expect(context?.runtimeContextKey).toMatch(/^runtime:runtime-1#\d+$/) + expect(localPreflightContextKey(context)).toBe(context?.runtimeContextKey) + }) + it('uses the selected WSL distro for local agent checks when WSL is the default shell', () => { const state = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index ed61a7619ec..8f28f805e17 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -1,7 +1,10 @@ import type { AppState } from '@/store/types' import { parseWslUncPath } from '../../../shared/wsl-paths' +import { getProviderRuntimeContextKey } from './provider-runtime-context' -export type LocalPreflightContext = { wslDistro?: string | null; wslDefault?: boolean } | undefined +export type LocalPreflightContext = + | { wslDistro?: string | null; wslDefault?: boolean; runtimeContextKey?: string } + | undefined const wslPreflightContextsByDistro = new Map>() const wslDefaultPreflightContext = Object.freeze({ wslDefault: true }) @@ -24,6 +27,9 @@ function getWslPreflightContext(wslDistro: string): NonNullable | null | undefined +): string { + const environmentId = settings?.activeRuntimeEnvironmentId?.trim() + const baseKey = environmentId ? `runtime:${environmentId}` : 'local' + return `${baseKey}#${providerRuntimeSessionGeneration}` +} + +export function hasRemoteProviderRuntime( + settings: Pick | null | undefined +): boolean { + return Boolean(settings?.activeRuntimeEnvironmentId?.trim()) +} + +let providerRuntimeSessionGeneration = 0 + +export function bumpProviderRuntimeSessionGeneration(): number { + providerRuntimeSessionGeneration += 1 + return providerRuntimeSessionGeneration +} diff --git a/src/renderer/src/store/slices/jira.test.ts b/src/renderer/src/store/slices/jira.test.ts index 6d117f9b703..2d6addc7972 100644 --- a/src/renderer/src/store/slices/jira.test.ts +++ b/src/renderer/src/store/slices/jira.test.ts @@ -1,24 +1,36 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' -import type { JiraIssue } from '../../../../shared/types' +import type { JiraConnectionStatus, JiraIssue, JiraViewer } from '../../../../shared/types' import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors' import { createJiraSlice } from './jira' +const jiraStatus = vi.fn() +const jiraConnect = vi.fn() +const jiraDisconnect = vi.fn() const jiraGetIssue = vi.fn() const jiraListIssues = vi.fn() const jiraSearchIssues = vi.fn() -const jiraStatus = vi.fn() +const jiraSelectSite = vi.fn() +const jiraTestConnection = vi.fn() vi.mock('@/runtime/runtime-jira-client', () => ({ - jiraConnect: vi.fn(), - jiraDisconnect: vi.fn(), + jiraAddIssueComment: vi.fn(), + jiraConnect: (...args: unknown[]) => jiraConnect(...args), + jiraCreateIssue: vi.fn(), + jiraDisconnect: (...args: unknown[]) => jiraDisconnect(...args), jiraGetIssue: (...args: unknown[]) => jiraGetIssue(...args), + jiraIssueComments: vi.fn(), + jiraListCreateFields: vi.fn(), + jiraListIssueTypes: vi.fn(), jiraListIssues: (...args: unknown[]) => jiraListIssues(...args), + jiraListPriorities: vi.fn(), + jiraListProjects: vi.fn(), jiraSearchIssues: (...args: unknown[]) => jiraSearchIssues(...args), - jiraSelectSite: vi.fn(), + jiraSelectSite: (...args: unknown[]) => jiraSelectSite(...args), jiraStatus: (...args: unknown[]) => jiraStatus(...args), - jiraTestConnection: vi.fn() + jiraTestConnection: (...args: unknown[]) => jiraTestConnection(...args), + jiraUpdateIssue: vi.fn() })) function createTestStore() { @@ -31,6 +43,18 @@ function createTestStore() { ) } +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +function status(email: string): JiraConnectionStatus { + return { connected: true, viewer: { email } as JiraViewer } +} + function issue(key: string): JiraIssue { return { id: key, @@ -48,6 +72,99 @@ function issue(key: string): JiraIssue { } } +describe('createJiraSlice runtime context', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('ignores stale status responses after the active runtime changes', async () => { + const store = createTestStore() + const localStatus = deferred() + const remoteStatus = deferred() + jiraStatus.mockReturnValueOnce(localStatus.promise).mockReturnValueOnce(remoteStatus.promise) + + const localRequest = store.getState().checkJiraConnection() + store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never }) + const remoteRequest = store.getState().checkJiraConnection() + + remoteStatus.resolve(status('remote@example.com')) + await remoteRequest + expect(store.getState().jiraStatus.viewer?.email).toBe('remote@example.com') + expect(store.getState().jiraStatusContextKey).toBe('runtime:runtime-1#0') + + localStatus.resolve(status('local@example.com')) + await localRequest + expect(store.getState().jiraStatus.viewer?.email).toBe('remote@example.com') + expect(store.getState().jiraStatusContextKey).toBe('runtime:runtime-1#0') + }) + + it('ignores stale issue cache writes after the active runtime changes', async () => { + const store = createTestStore() + const localIssue = deferred() + const remoteIssue = deferred() + jiraGetIssue.mockReturnValueOnce(localIssue.promise).mockReturnValueOnce(remoteIssue.promise) + + const localRequest = store.getState().fetchJiraIssue('ORC-1') + store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never }) + const remoteRequest = store.getState().fetchJiraIssue('ORC-1') + + remoteIssue.resolve({ ...issue('ORC-1'), title: 'Remote issue' }) + await remoteRequest + expect(store.getState().jiraIssueCache['selected::ORC-1']?.data?.title).toBe('Remote issue') + + localIssue.resolve({ ...issue('ORC-1'), title: 'Local issue' }) + await localRequest + expect(store.getState().jiraIssueCache['selected::ORC-1']?.data?.title).toBe('Remote issue') + }) + + it('returns a failed Jira connect result when the active runtime changes before completion', async () => { + const store = createTestStore() + const connectResult = deferred<{ ok: true; viewer: JiraViewer }>() + jiraConnect.mockReturnValueOnce(connectResult.promise) + + const request = store.getState().connectJira({ + siteUrl: 'https://example.atlassian.net', + email: 'local@example.com', + apiToken: 'token' + }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never }) + + connectResult.resolve({ ok: true, viewer: { email: 'local@example.com' } as JiraViewer }) + await expect(request).resolves.toEqual({ + ok: false, + error: 'Jira connection was superseded by a newer request.' + }) + expect(store.getState().jiraStatus.connected).toBe(false) + expect(store.getState().jiraStatusContextKey).toBeNull() + }) + + it('does not run a stale test follow-up status check after the active runtime changes', async () => { + const store = createTestStore() + const testResult = deferred<{ ok: true; viewer: JiraViewer }>() + jiraTestConnection.mockReturnValueOnce(testResult.promise) + + const request = store.getState().testJiraConnection() + store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never }) + + testResult.resolve({ ok: true, viewer: { email: 'local@example.com' } as JiraViewer }) + await request + expect(jiraStatus).not.toHaveBeenCalled() + }) + + it('does not clear or refresh stale disconnect results after the active runtime changes', async () => { + const store = createTestStore() + const disconnectResult = deferred() + jiraDisconnect.mockReturnValueOnce(disconnectResult.promise) + + const request = store.getState().disconnectJira() + store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never }) + + disconnectResult.resolve() + await request + expect(jiraStatus).not.toHaveBeenCalled() + }) +}) + describe('createJiraSlice credential errors', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index 40a80db707c..1ee48d0de21 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -22,6 +22,8 @@ import { jiraStatus, jiraTestConnection } from '@/runtime/runtime-jira-client' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { translate } from '@/i18n/i18n' const CACHE_TTL = 60_000 const MAX_CACHE_ENTRIES = 500 @@ -51,9 +53,17 @@ function looksLikeAuthError(error: unknown): boolean { return /authenticat|unauthorized|forbidden|401|403/i.test(msg) } -const inflightIssueRequests = new Map>() -const inflightSearchRequests = new Map>() -const inflightListRequests = new Map>() +type InflightJiraReadRequest = { + promise: Promise + contextKey: string + mutationGeneration: number +} + +const inflightIssueRequests = new Map>() +const inflightSearchRequests = new Map>() +const inflightListRequests = new Map>() +let jiraStatusReadGeneration = 0 +let jiraMutationGeneration = 0 function getSelectedSiteId(status: JiraConnectionStatus): JiraSiteSelection | null { return status.selectedSiteId ?? status.activeSiteId ?? null @@ -65,9 +75,34 @@ function clearJiraInflight(): void { inflightListRequests.clear() } +function beginJiraMutation(): number { + jiraMutationGeneration += 1 + return jiraMutationGeneration +} + +function isCurrentJiraMutation(generation: number): boolean { + return generation === jiraMutationGeneration +} + +function isCurrentJiraRuntimeContext(contextKey: string, settings: AppState['settings']): boolean { + return getProviderRuntimeContextKey(settings) === contextKey +} + +function canWriteJiraReadResult( + contextKey: string, + mutationGeneration: number, + settings: AppState['settings'] +): boolean { + return ( + mutationGeneration === jiraMutationGeneration && + isCurrentJiraRuntimeContext(contextKey, settings) + ) +} + export type JiraSlice = { jiraStatus: JiraConnectionStatus jiraStatusChecked: boolean + jiraStatusContextKey: string | null jiraIssueCache: Record> jiraSearchCache: Record> @@ -91,12 +126,26 @@ export type JiraSlice = { export const createJiraSlice: StateCreator = (set, get) => ({ jiraStatus: { connected: false, viewer: null }, jiraStatusChecked: false, + jiraStatusContextKey: null, jiraIssueCache: {}, jiraSearchCache: {}, checkJiraConnection: async () => { + const contextKey = getProviderRuntimeContextKey(get().settings) + const statusReadGeneration = (jiraStatusReadGeneration += 1) + const mutationGeneration = jiraMutationGeneration + if (get().jiraStatusContextKey !== contextKey) { + set({ jiraStatusChecked: false }) + } try { const status = await jiraStatus(get().settings) + if ( + mutationGeneration !== jiraMutationGeneration || + statusReadGeneration !== jiraStatusReadGeneration || + getProviderRuntimeContextKey(get().settings) !== contextKey + ) { + return + } const prev = get().jiraStatus if ( prev.connected !== status.connected || @@ -105,25 +154,58 @@ export const createJiraSlice: StateCreator = (set, getSelectedSiteId(prev) !== getSelectedSiteId(status) || (prev.sites?.length ?? 0) !== (status.sites?.length ?? 0) ) { - set({ jiraStatus: status, jiraStatusChecked: true }) + set({ jiraStatus: status, jiraStatusChecked: true, jiraStatusContextKey: contextKey }) } else if (!get().jiraStatusChecked) { - set({ jiraStatusChecked: true }) + set({ jiraStatusChecked: true, jiraStatusContextKey: contextKey }) + } else if (get().jiraStatusContextKey !== contextKey) { + set({ jiraStatusContextKey: contextKey }) } } catch { + if ( + mutationGeneration !== jiraMutationGeneration || + statusReadGeneration !== jiraStatusReadGeneration || + getProviderRuntimeContextKey(get().settings) !== contextKey + ) { + return + } if (get().jiraStatus.connected) { - set({ jiraStatus: { connected: false, viewer: null }, jiraStatusChecked: true }) + set({ + jiraStatus: { connected: false, viewer: null }, + jiraStatusChecked: true, + jiraStatusContextKey: contextKey + }) } else if (!get().jiraStatusChecked) { - set({ jiraStatusChecked: true }) + set({ jiraStatusChecked: true, jiraStatusContextKey: contextKey }) + } else if (get().jiraStatusContextKey !== contextKey) { + set({ jiraStatusContextKey: contextKey }) } } }, connectJira: async (args) => { + const requestGeneration = beginJiraMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) try { const result = await jiraConnect(get().settings, args) - if (result.ok) { - set({ jiraStatus: { connected: true, viewer: result.viewer }, jiraStatusChecked: true }) + if ( + result.ok && + isCurrentJiraMutation(requestGeneration) && + isCurrentJiraRuntimeContext(contextKey, get().settings) + ) { + set({ + jiraStatus: { connected: true, viewer: result.viewer }, + jiraStatusChecked: true, + jiraStatusContextKey: contextKey + }) void get().checkJiraConnection() + } else if (result.ok) { + return { + ok: false as const, + error: translate( + 'auto.store.slices.jira.856083302c', + 'Jira connection was superseded by a newer request.' + ) + } } return result } catch (error) { @@ -133,10 +215,23 @@ export const createJiraSlice: StateCreator = (set, }, testJiraConnection: async (siteId) => { + const requestGeneration = beginJiraMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) try { const result = await jiraTestConnection(get().settings, siteId) + if ( + !isCurrentJiraMutation(requestGeneration) || + !isCurrentJiraRuntimeContext(contextKey, get().settings) + ) { + return result + } const status = await jiraStatus(get().settings) - set({ jiraStatus: status, jiraStatusChecked: true }) + if ( + isCurrentJiraMutation(requestGeneration) && + isCurrentJiraRuntimeContext(contextKey, get().settings) + ) { + set({ jiraStatus: status, jiraStatusChecked: true, jiraStatusContextKey: contextKey }) + } return result } catch (error) { const message = error instanceof Error ? error.message : 'Test failed' @@ -145,65 +240,111 @@ export const createJiraSlice: StateCreator = (set, }, selectJiraSite: async (siteId) => { + const requestGeneration = beginJiraMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) const status = await jiraSelectSite(get().settings, siteId) + if ( + !isCurrentJiraMutation(requestGeneration) || + getProviderRuntimeContextKey(get().settings) !== contextKey + ) { + return + } clearJiraInflight() set({ jiraStatus: status, jiraIssueCache: {}, jiraSearchCache: {}, - jiraStatusChecked: true + jiraStatusChecked: true, + jiraStatusContextKey: contextKey }) }, disconnectJira: async (siteId) => { + const requestGeneration = beginJiraMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) await jiraDisconnect(get().settings, siteId) + if ( + !isCurrentJiraMutation(requestGeneration) || + !isCurrentJiraRuntimeContext(contextKey, get().settings) + ) { + return + } clearJiraInflight() const status = await jiraStatus(get().settings) + if ( + !isCurrentJiraMutation(requestGeneration) || + !isCurrentJiraRuntimeContext(contextKey, get().settings) + ) { + return + } set({ jiraStatus: status.connected ? status : { connected: false, viewer: null }, jiraIssueCache: {}, jiraSearchCache: {}, - jiraStatusChecked: true + jiraStatusChecked: true, + jiraStatusContextKey: contextKey }) }, fetchJiraIssue: async (key, siteId) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const issueCacheKey = `${siteId ?? 'selected'}::${key}` const cached = get().jiraIssueCache[issueCacheKey] ?? get().jiraIssueCache[key] if (isFresh(cached)) { return cached.data } const inflight = inflightIssueRequests.get(issueCacheKey) - if (inflight) { - return inflight + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === jiraMutationGeneration + ) { + return inflight.promise } + let entry: InflightJiraReadRequest + const requestMutationGeneration = jiraMutationGeneration const promise = jiraGetIssue(get().settings, key, siteId) .then((issue) => { - set((s) => ({ - jiraIssueCache: evictStaleEntries({ - ...s.jiraIssueCache, - [issueCacheKey]: { data: issue, fetchedAt: Date.now() } - }) - })) + if ( + inflightIssueRequests.get(issueCacheKey) === entry && + canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + ) { + set((s) => ({ + jiraIssueCache: evictStaleEntries({ + ...s.jiraIssueCache, + [issueCacheKey]: { data: issue, fetchedAt: Date.now() } + }) + })) + } return issue }) .catch((error) => { console.warn('[jira] fetchJiraIssue failed:', error) - if (isIntegrationCredentialDecryptionError(error)) { + if ( + isIntegrationCredentialDecryptionError(error) && + canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + ) { void get().checkJiraConnection() - } else if (looksLikeAuthError(error)) { + } else if ( + looksLikeAuthError(error) && + canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + ) { set({ jiraStatus: { connected: false, viewer: null } }) } return null }) .finally(() => { - inflightIssueRequests.delete(issueCacheKey) + if (inflightIssueRequests.get(issueCacheKey) === entry) { + inflightIssueRequests.delete(issueCacheKey) + } }) - inflightIssueRequests.set(issueCacheKey, promise) + entry = { promise, contextKey, mutationGeneration: requestMutationGeneration } + inflightIssueRequests.set(issueCacheKey, entry) return promise }, searchJiraIssues: async (jql, limit = 30) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const siteId = getSelectedSiteId(get().jiraStatus) const cacheKey = `${siteId ?? 'default'}::${jql}::${limit}` const cached = get().jiraSearchCache[cacheKey] @@ -211,36 +352,57 @@ export const createJiraSlice: StateCreator = (set, return cached.data ?? [] } const inflight = inflightSearchRequests.get(cacheKey) - if (inflight) { - return inflight + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === jiraMutationGeneration + ) { + return inflight.promise } + let entry: InflightJiraReadRequest + const requestMutationGeneration = jiraMutationGeneration const promise = jiraSearchIssues(get().settings, jql, limit, siteId) .then((issues) => { - set((s) => ({ - jiraSearchCache: evictStaleEntries({ - ...s.jiraSearchCache, - [cacheKey]: { data: issues, fetchedAt: Date.now() } - }) - })) + if ( + inflightSearchRequests.get(cacheKey) === entry && + canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + ) { + set((s) => ({ + jiraSearchCache: evictStaleEntries({ + ...s.jiraSearchCache, + [cacheKey]: { data: issues, fetchedAt: Date.now() } + }) + })) + } return issues }) .catch((error) => { console.warn('[jira] searchJiraIssues failed:', error) - if (isIntegrationCredentialDecryptionError(error)) { + if ( + isIntegrationCredentialDecryptionError(error) && + canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + ) { void get().checkJiraConnection() - } else if (looksLikeAuthError(error)) { + } else if ( + looksLikeAuthError(error) && + canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + ) { set({ jiraStatus: { connected: false, viewer: null } }) } return [] }) .finally(() => { - inflightSearchRequests.delete(cacheKey) + if (inflightSearchRequests.get(cacheKey) === entry) { + inflightSearchRequests.delete(cacheKey) + } }) - inflightSearchRequests.set(cacheKey, promise) + entry = { promise, contextKey, mutationGeneration: requestMutationGeneration } + inflightSearchRequests.set(cacheKey, entry) return promise }, listJiraIssues: async (filter = 'assigned', limit = 30) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const siteId = getSelectedSiteId(get().jiraStatus) const cacheKey = `${siteId ?? 'default'}::list::${filter}::${limit}` const cached = get().jiraSearchCache[cacheKey] @@ -248,32 +410,52 @@ export const createJiraSlice: StateCreator = (set, return cached.data ?? [] } const inflight = inflightListRequests.get(cacheKey) - if (inflight) { - return inflight + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === jiraMutationGeneration + ) { + return inflight.promise } + let entry: InflightJiraReadRequest + const requestMutationGeneration = jiraMutationGeneration const promise = jiraListIssues(get().settings, filter, limit, siteId) .then((issues) => { - set((s) => ({ - jiraSearchCache: evictStaleEntries({ - ...s.jiraSearchCache, - [cacheKey]: { data: issues, fetchedAt: Date.now() } - }) - })) + if ( + inflightListRequests.get(cacheKey) === entry && + canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + ) { + set((s) => ({ + jiraSearchCache: evictStaleEntries({ + ...s.jiraSearchCache, + [cacheKey]: { data: issues, fetchedAt: Date.now() } + }) + })) + } return issues }) .catch((error) => { console.warn('[jira] listJiraIssues failed:', error) - if (isIntegrationCredentialDecryptionError(error)) { + if ( + isIntegrationCredentialDecryptionError(error) && + canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + ) { void get().checkJiraConnection() - } else if (looksLikeAuthError(error)) { + } else if ( + looksLikeAuthError(error) && + canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + ) { set({ jiraStatus: { connected: false, viewer: null } }) } return [] }) .finally(() => { - inflightListRequests.delete(cacheKey) + if (inflightListRequests.get(cacheKey) === entry) { + inflightListRequests.delete(cacheKey) + } }) - inflightListRequests.set(cacheKey, promise) + entry = { promise, contextKey, mutationGeneration: requestMutationGeneration } + inflightListRequests.set(cacheKey, entry) return promise }, diff --git a/src/renderer/src/store/slices/linear.test.ts b/src/renderer/src/store/slices/linear.test.ts index 6f40e1adc7b..60a39897eae 100644 --- a/src/renderer/src/store/slices/linear.test.ts +++ b/src/renderer/src/store/slices/linear.test.ts @@ -757,6 +757,61 @@ describe('createLinearSlice', () => { expect(store.getState().linearStatus.viewer?.email).toBe('test@example.com') }) + it('ignores stale status responses after the active runtime changes', async () => { + const localStatus = deferred() + const remoteStatus = deferred() + const localViewer = { + displayName: 'Local User', + email: 'local@example.com', + organizationName: 'Local Org' + } + const remoteViewer = { + displayName: 'Remote User', + email: 'remote@example.com', + organizationName: 'Remote Org' + } + linearStatus.mockReturnValueOnce(localStatus.promise).mockReturnValueOnce(remoteStatus.promise) + const store = createTestStore() + + const localRequest = store.getState().checkLinearConnection() + store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never }) + const remoteRequest = store.getState().checkLinearConnection() + + remoteStatus.resolve({ connected: true, viewer: remoteViewer }) + await remoteRequest + expect(store.getState().linearStatus.viewer?.email).toBe('remote@example.com') + expect(store.getState().linearStatusContextKey).toBe('runtime:runtime-1#0') + + localStatus.resolve({ connected: true, viewer: localViewer }) + await localRequest + expect(store.getState().linearStatus.viewer?.email).toBe('remote@example.com') + expect(store.getState().linearStatusContextKey).toBe('runtime:runtime-1#0') + }) + + it('ignores stale list cache writes after the active runtime changes', async () => { + const localList = deferred>() + const remoteList = deferred>() + linearListIssues.mockReturnValueOnce(localList.promise).mockReturnValueOnce(remoteList.promise) + const store = createTestStore() + store.setState({ linearStatus: { connected: true, viewer: null } }) + + const localRequest = store.getState().listLinearIssues('assigned', 20) + store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never }) + const remoteRequest = store.getState().listLinearIssues('assigned', 20) + + remoteList.resolve({ items: [issue('LIN-REMOTE')] }) + await remoteRequest + expect( + store.getState().getCachedLinearIssues({ kind: 'list', filter: 'assigned', limit: 20 }) + ).toMatchObject({ items: [{ id: 'LIN-REMOTE' }] }) + + localList.resolve({ items: [issue('LIN-LOCAL')] }) + await localRequest + expect( + store.getState().getCachedLinearIssues({ kind: 'list', filter: 'assigned', limit: 20 }) + ).toMatchObject({ items: [{ id: 'LIN-REMOTE' }] }) + }) + it('ignores stale status checks after a successful connect', async () => { const staleMountCheck = deferred() const freshConnectCheck = deferred() @@ -787,6 +842,29 @@ describe('createLinearSlice', () => { expect(store.getState().linearStatus.viewer?.email).toBe('test@example.com') }) + it('ignores stale connect results after the active runtime changes', async () => { + const connectResult = deferred<{ ok: true; viewer: LinearViewer }>() + const viewer = { + displayName: 'Local User', + email: 'local@example.com', + organizationName: 'Local Org' + } + linearConnect.mockReturnValueOnce(connectResult.promise) + const store = createTestStore() + + const connectPromise = store.getState().connectLinear('linear-key') + store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never }) + + connectResult.resolve({ ok: true, viewer }) + await expect(connectPromise).resolves.toEqual({ + ok: false, + error: 'Linear connection was superseded by a newer request.' + }) + + expect(store.getState().linearStatus.connected).toBe(false) + expect(store.getState().linearStatusContextKey).toBeNull() + }) + it('does not let a background status refresh cancel an in-flight connect', async () => { const connectResult = deferred<{ ok: true; viewer: LinearViewer }>() const backgroundStatus = deferred() diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index eb078d53cf9..27fa90cdee6 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -40,6 +40,8 @@ import { linearStatus, linearTestConnection } from '@/runtime/runtime-linear-client' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { translate } from '@/i18n/i18n' const CACHE_TTL = 60_000 // 60s — same as GitHub work-items revalidation TTL const TEAM_CACHE_TTL = 10 * 60_000 // Teams change rarely and block visible Linear rows. @@ -73,6 +75,8 @@ function looksLikeAuthError(error: unknown): boolean { type InflightLinearIssueRequest = { promise: Promise generation: number + contextKey: string + mutationGeneration: number } function workspaceErrorType(error: unknown): LinearWorkspaceError['type'] { @@ -105,20 +109,28 @@ type InflightLinearListRequest = { promise: Promise force: boolean generation: number + contextKey: string + mutationGeneration: number } type InflightLinearPlainListRequest = { promise: Promise> force: boolean generation: number + contextKey: string + mutationGeneration: number } type InflightLinearCollectionRequest = { promise: Promise> force: boolean generation: number + contextKey: string + mutationGeneration: number } type InflightLinearDetailRequest = { promise: Promise force: boolean + contextKey: string + mutationGeneration: number } const inflightSearchRequests = new Map() @@ -127,6 +139,8 @@ type InflightLinearTeamRequest = { promise: Promise force: boolean generation: number + contextKey: string + mutationGeneration: number } const inflightTeamRequests = new Map() @@ -155,7 +169,7 @@ const inflightCustomViewProjectRequests = new Map< string, InflightLinearCollectionRequest >() -let inflightStatusRequest: Promise | null = null +let inflightStatusRequest: { contextKey: string; promise: Promise } | null = null let linearStatusReadGeneration = 0 let linearMutationGeneration = 0 let linearCacheGeneration = 0 @@ -341,9 +355,30 @@ function isCurrentLinearMutation(generation: number): boolean { return generation === linearMutationGeneration } +function isCurrentLinearRuntimeContext( + contextKey: string, + settings: AppState['settings'] +): boolean { + return getProviderRuntimeContextKey(settings) === contextKey +} + +function canWriteLinearReadResult( + contextKey: string, + generation: number, + mutationGeneration: number, + settings: AppState['settings'] +): boolean { + return ( + generation === linearCacheGeneration && + mutationGeneration === linearMutationGeneration && + isCurrentLinearRuntimeContext(contextKey, settings) + ) +} + export type LinearSlice = { linearStatus: LinearConnectionStatus linearStatusChecked: boolean + linearStatusContextKey: string | null linearIssueCache: Record> linearSearchCache: Record> linearListCache: Record>> @@ -446,6 +481,7 @@ export type LinearSlice = { export const createLinearSlice: StateCreator = (set, get) => ({ linearStatus: { connected: false, viewer: null }, linearStatusChecked: false, + linearStatusContextKey: null, linearIssueCache: {}, linearSearchCache: {}, linearListCache: {}, @@ -459,17 +495,22 @@ export const createLinearSlice: StateCreator = (s linearCustomViewProjectCache: {}, checkLinearConnection: async (force = false) => { - if (inflightStatusRequest && !force) { - return inflightStatusRequest + const contextKey = getProviderRuntimeContextKey(get().settings) + if (inflightStatusRequest && !force && inflightStatusRequest.contextKey === contextKey) { + return inflightStatusRequest.promise + } + if (get().linearStatusContextKey !== contextKey) { + set({ linearStatusChecked: false }) } const mutationGeneration = linearMutationGeneration const statusReadGeneration = (linearStatusReadGeneration += 1) - inflightStatusRequest = linearStatus(get().settings) + const request = linearStatus(get().settings) .then((status) => { if ( mutationGeneration !== linearMutationGeneration || - statusReadGeneration !== linearStatusReadGeneration + statusReadGeneration !== linearStatusReadGeneration || + !isCurrentLinearRuntimeContext(contextKey, get().settings) ) { return } @@ -492,16 +533,20 @@ export const createLinearSlice: StateCreator = (s linearCustomViewDetailCache: {}, linearCustomViewIssueCache: {}, linearCustomViewProjectCache: {}, - linearStatusChecked: true + linearStatusChecked: true, + linearStatusContextKey: contextKey }) } else if (!get().linearStatusChecked) { - set({ linearStatusChecked: true }) + set({ linearStatusChecked: true, linearStatusContextKey: contextKey }) + } else if (get().linearStatusContextKey !== contextKey) { + set({ linearStatusContextKey: contextKey }) } }) .catch(() => { if ( mutationGeneration !== linearMutationGeneration || - statusReadGeneration !== linearStatusReadGeneration + statusReadGeneration !== linearStatusReadGeneration || + !isCurrentLinearRuntimeContext(contextKey, get().settings) ) { return } @@ -520,29 +565,46 @@ export const createLinearSlice: StateCreator = (s linearCustomViewDetailCache: {}, linearCustomViewIssueCache: {}, linearCustomViewProjectCache: {}, - linearStatusChecked: true + linearStatusChecked: true, + linearStatusContextKey: contextKey }) } else if (!get().linearStatusChecked) { - set({ linearStatusChecked: true }) + set({ linearStatusChecked: true, linearStatusContextKey: contextKey }) + } else if (get().linearStatusContextKey !== contextKey) { + set({ linearStatusContextKey: contextKey }) } }) .finally(() => { - if (statusReadGeneration === linearStatusReadGeneration) { + if ( + statusReadGeneration === linearStatusReadGeneration && + inflightStatusRequest?.promise === request + ) { inflightStatusRequest = null } }) + inflightStatusRequest = { contextKey, promise: request } - return inflightStatusRequest + return request }, testLinearConnection: async (workspaceId) => { const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) try { const result = (await linearTestConnection(get().settings, workspaceId)) as | { ok: true; viewer: LinearViewer } | { ok: false; error: string } + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return result + } const status = await linearStatus(get().settings) - if (isCurrentLinearMutation(requestGeneration)) { + if ( + isCurrentLinearMutation(requestGeneration) && + isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { const prev = get().linearStatus if (linearStatusScopeSignature(prev) !== linearStatusScopeSignature(status)) { invalidateLinearCaches() @@ -559,10 +621,15 @@ export const createLinearSlice: StateCreator = (s linearCustomViewDetailCache: {}, linearCustomViewIssueCache: {}, linearCustomViewProjectCache: {}, - linearStatusChecked: true + linearStatusChecked: true, + linearStatusContextKey: contextKey }) } else { - set({ linearStatus: status, linearStatusChecked: true }) + set({ + linearStatus: status, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) } } return result @@ -574,9 +641,14 @@ export const createLinearSlice: StateCreator = (s connectLinear: async (apiKey: string) => { const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) try { const result = await linearConnect(get().settings, apiKey) - if (result.ok && isCurrentLinearMutation(requestGeneration)) { + if ( + result.ok && + isCurrentLinearMutation(requestGeneration) && + isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { invalidateLinearCaches() set({ linearIssueCache: {}, @@ -592,13 +664,31 @@ export const createLinearSlice: StateCreator = (s linearCustomViewProjectCache: {} }) const status = await linearStatus(get().settings) - if (!isCurrentLinearMutation(requestGeneration)) { - return result as { ok: true; viewer: LinearViewer } | { ok: false; error: string } + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return { + ok: false as const, + error: translate( + 'auto.store.slices.linear.37d36984d0', + 'Linear connection was superseded by a newer request.' + ) + } } set({ linearStatus: status, - linearStatusChecked: true + linearStatusChecked: true, + linearStatusContextKey: contextKey }) + } else if (result.ok) { + return { + ok: false as const, + error: translate( + 'auto.store.slices.linear.37d36984d0', + 'Linear connection was superseded by a newer request.' + ) + } } return result as { ok: true; viewer: LinearViewer } | { ok: false; error: string } } catch (error) { @@ -609,8 +699,12 @@ export const createLinearSlice: StateCreator = (s selectLinearWorkspace: async (workspaceId) => { const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) const status = await linearSelectWorkspace(get().settings, workspaceId) - if (!isCurrentLinearMutation(requestGeneration)) { + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { return } invalidateLinearCaches() @@ -627,14 +721,19 @@ export const createLinearSlice: StateCreator = (s linearCustomViewDetailCache: {}, linearCustomViewIssueCache: {}, linearCustomViewProjectCache: {}, - linearStatusChecked: true + linearStatusChecked: true, + linearStatusContextKey: contextKey }) }, disconnectLinear: async () => { const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) await linearDisconnect(get().settings) - if (!isCurrentLinearMutation(requestGeneration)) { + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { return } invalidateLinearCaches() @@ -651,15 +750,26 @@ export const createLinearSlice: StateCreator = (s linearCustomViewDetailCache: {}, linearCustomViewIssueCache: {}, linearCustomViewProjectCache: {}, - linearStatusChecked: true + linearStatusChecked: true, + linearStatusContextKey: contextKey }) }, disconnectLinearWorkspace: async (workspaceId) => { const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) await linearDisconnectWorkspace(get().settings, workspaceId) + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return + } const status = await linearStatus(get().settings) - if (!isCurrentLinearMutation(requestGeneration)) { + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { return } invalidateLinearCaches() @@ -676,11 +786,13 @@ export const createLinearSlice: StateCreator = (s linearCustomViewDetailCache: {}, linearCustomViewIssueCache: {}, linearCustomViewProjectCache: {}, - linearStatusChecked: true + linearStatusChecked: true, + linearStatusContextKey: contextKey }) }, fetchLinearIssue: async (id: string, workspaceId?: string | null) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const issueCacheKey = `${workspaceId ?? 'selected'}::${id}` const cached = get().linearIssueCache[issueCacheKey] ?? get().linearIssueCache[id] if (isFresh(cached)) { @@ -688,18 +800,28 @@ export const createLinearSlice: StateCreator = (s } const inflight = inflightIssueRequests.get(issueCacheKey) - if (inflight) { + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === linearMutationGeneration + ) { return inflight.promise } let entry: InflightLinearIssueRequest const requestCacheGeneration = linearCacheGeneration + const requestMutationGeneration = linearMutationGeneration const promise = linearGetIssue(get().settings, id, workspaceId) .then((issue) => { const data = issue as LinearIssue | null if ( inflightIssueRequests.get(issueCacheKey) === entry && - requestCacheGeneration === linearCacheGeneration + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) ) { set((s) => ({ linearIssueCache: evictStaleEntries({ @@ -712,7 +834,15 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] fetchLinearIssue failed:', error) - if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) { + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { void get().checkLinearConnection(true) } return null @@ -723,7 +853,12 @@ export const createLinearSlice: StateCreator = (s } }) - entry = { promise, generation: requestCacheGeneration } + entry = { + promise, + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } inflightIssueRequests.set(issueCacheKey, entry) return promise }, @@ -740,11 +875,18 @@ export const createLinearSlice: StateCreator = (s }, prefetchLinearIssues: (args) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const workspaceId = getSelectedWorkspaceId(get().linearStatus) if (args.kind === 'search') { const limit = args.limit ?? 20 const cacheKey = linearSearchCacheKey(workspaceId, args.query, limit) - if (isFresh(get().linearSearchCache[cacheKey]) || inflightSearchRequests.has(cacheKey)) { + const inflight = inflightSearchRequests.get(cacheKey) + if ( + isFresh(get().linearSearchCache[cacheKey]) || + (inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === linearMutationGeneration) + ) { return } void get() @@ -754,7 +896,13 @@ export const createLinearSlice: StateCreator = (s } const limit = clampLinearIssueListLimit(args.limit) const cacheKey = linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit) - if (isFresh(get().linearListCache[cacheKey]) || inflightListRequests.has(cacheKey)) { + const inflight = inflightListRequests.get(cacheKey) + if ( + isFresh(get().linearListCache[cacheKey]) || + (inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === linearMutationGeneration) + ) { return } void get() @@ -763,6 +911,7 @@ export const createLinearSlice: StateCreator = (s }, searchLinearIssues: async (query: string, limit = 20, options) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const workspaceId = getSelectedWorkspaceId(get().linearStatus) const cacheKey = linearSearchCacheKey(workspaceId, query, limit) const cached = get().linearSearchCache[cacheKey] @@ -771,18 +920,29 @@ export const createLinearSlice: StateCreator = (s } const inflight = inflightSearchRequests.get(cacheKey) - if (inflight && (!options?.force || inflight.force)) { + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === linearMutationGeneration && + (!options?.force || inflight.force) + ) { return inflight.promise } let entry: InflightLinearListRequest const requestCacheGeneration = linearCacheGeneration + const requestMutationGeneration = linearMutationGeneration const promise = linearSearchIssues(get().settings, query, limit, workspaceId) .then((issues) => { const data = issues as LinearIssue[] if ( inflightSearchRequests.get(cacheKey) === entry && - requestCacheGeneration === linearCacheGeneration + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) ) { set((s) => ({ linearSearchCache: evictStaleEntries({ @@ -795,7 +955,15 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] searchLinearIssues failed:', error) - if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) { + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { if (!shouldRefreshStatusAfterRead(workspaceId)) { void get().checkLinearConnection(true) } @@ -809,18 +977,30 @@ export const createLinearSlice: StateCreator = (s } if ( shouldRefreshStatusAfterRead(workspaceId) && - requestCacheGeneration === linearCacheGeneration + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) ) { void get().checkLinearConnection(true) } }) - entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration } + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } inflightSearchRequests.set(cacheKey, entry) return promise }, listLinearIssues: async (filter = 'assigned', limit = 20, options) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const workspaceId = getSelectedWorkspaceId(get().linearStatus) const effectiveLimit = clampLinearIssueListLimit(limit) const cacheKey = linearListCacheKey(workspaceId, filter, effectiveLimit) @@ -830,12 +1010,18 @@ export const createLinearSlice: StateCreator = (s } const inflight = inflightListRequests.get(cacheKey) - if (inflight && (!options?.force || inflight.force)) { + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === linearMutationGeneration && + (!options?.force || inflight.force) + ) { return inflight.promise } let entry: InflightLinearPlainListRequest const requestCacheGeneration = linearCacheGeneration + const requestMutationGeneration = linearMutationGeneration const promise: Promise> = linearListIssues( get().settings, filter, @@ -846,7 +1032,12 @@ export const createLinearSlice: StateCreator = (s const data = result as LinearCollectionResult if ( inflightListRequests.get(cacheKey) === entry && - requestCacheGeneration === linearCacheGeneration + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) ) { set((s) => ({ linearListCache: evictStaleEntries({ @@ -859,7 +1050,15 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearIssues failed:', error) - if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) { + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { if (!shouldRefreshStatusAfterRead(workspaceId)) { void get().checkLinearConnection(true) } @@ -873,13 +1072,24 @@ export const createLinearSlice: StateCreator = (s } if ( shouldRefreshStatusAfterRead(workspaceId) && - requestCacheGeneration === linearCacheGeneration + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) ) { void get().checkLinearConnection(true) } }) - entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration } + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } inflightListRequests.set(cacheKey, entry) return promise }, @@ -890,6 +1100,7 @@ export const createLinearSlice: StateCreator = (s }, listLinearTeams: async (workspaceId, options) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) const cacheKey = linearTeamsCacheKey(resolvedWorkspaceId) const cached = get().linearTeamCache[cacheKey] @@ -898,18 +1109,29 @@ export const createLinearSlice: StateCreator = (s } const inflight = inflightTeamRequests.get(cacheKey) - if (inflight && (!options?.force || inflight.force)) { + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === linearMutationGeneration && + (!options?.force || inflight.force) + ) { return inflight.promise } let entry: InflightLinearTeamRequest const requestCacheGeneration = linearCacheGeneration + const requestMutationGeneration = linearMutationGeneration const promise = linearListTeams(get().settings, resolvedWorkspaceId) .then((teams) => { const data = teams as LinearTeam[] if ( inflightTeamRequests.get(cacheKey) === entry && - requestCacheGeneration === linearCacheGeneration + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) ) { set((s) => ({ linearTeamCache: evictStaleEntries({ @@ -922,7 +1144,15 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearTeams failed:', error) - if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) { + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId)) { void get().checkLinearConnection(true) } @@ -936,13 +1166,24 @@ export const createLinearSlice: StateCreator = (s } if ( shouldRefreshStatusAfterRead(resolvedWorkspaceId) && - requestCacheGeneration === linearCacheGeneration + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) ) { void get().checkLinearConnection(true) } }) - entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration } + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } inflightTeamRequests.set(cacheKey, entry) return promise }, @@ -954,6 +1195,7 @@ export const createLinearSlice: StateCreator = (s }, listLinearProjects: async (query, limit = 20, workspaceId, options) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) const trimmed = query?.trim() || undefined const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'projects', trimmed, limit) @@ -963,19 +1205,30 @@ export const createLinearSlice: StateCreator = (s } const inflight = inflightProjectRequests.get(cacheKey) - if (inflight && (!options?.force || inflight.force)) { + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === linearMutationGeneration && + (!options?.force || inflight.force) + ) { return inflight.promise } let entry: InflightLinearCollectionRequest const requestCacheGeneration = linearCacheGeneration + const requestMutationGeneration = linearMutationGeneration const promise = linearListProjects(get().settings, trimmed, limit, resolvedWorkspaceId, { force: options?.force }) .then((result) => { if ( inflightProjectRequests.get(cacheKey) === entry && - requestCacheGeneration === linearCacheGeneration + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) ) { set((s) => ({ linearProjectCache: evictStaleEntries({ @@ -988,10 +1241,16 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearProjects failed:', error) - if (isIntegrationCredentialDecryptionError(error)) { + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { void get().checkLinearConnection(true) - } else if (looksLikeAuthError(error)) { - set({ linearStatus: { connected: false, viewer: null } }) } const fallback = get().linearProjectCache[cacheKey]?.data ?? emptyLinearCollection() @@ -1003,18 +1262,30 @@ export const createLinearSlice: StateCreator = (s } if ( shouldRefreshStatusAfterRead(resolvedWorkspaceId) && - requestCacheGeneration === linearCacheGeneration + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) ) { void get().checkLinearConnection(true) } }) - entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration } + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } inflightProjectRequests.set(cacheKey, entry) return promise }, fetchLinearProject: async (id, workspaceId, options) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const cacheKey = linearCollectionCacheKey(workspaceId, 'project-detail', id) const cached = get().linearProjectDetailCache[cacheKey] if (!options?.force && isFresh(cached)) { @@ -1022,16 +1293,31 @@ export const createLinearSlice: StateCreator = (s } const inflight = inflightProjectDetailRequests.get(cacheKey) - if (inflight && (!options?.force || inflight.force)) { + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === linearMutationGeneration && + (!options?.force || inflight.force) + ) { return inflight.promise } let entry: InflightLinearDetailRequest + const requestCacheGeneration = linearCacheGeneration + const requestMutationGeneration = linearMutationGeneration const promise = linearGetProject(get().settings, id, workspaceId, { force: options?.force }) .then((project) => { - if (inflightProjectDetailRequests.get(cacheKey) === entry) { + if ( + inflightProjectDetailRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { set((s) => ({ linearProjectDetailCache: evictStaleEntries({ ...s.linearProjectDetailCache, @@ -1043,10 +1329,16 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] fetchLinearProject failed:', error) - if (isIntegrationCredentialDecryptionError(error)) { + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { void get().checkLinearConnection(true) - } else if (looksLikeAuthError(error)) { - set({ linearStatus: { connected: false, viewer: null } }) } if (options?.force) { throw error @@ -1063,12 +1355,18 @@ export const createLinearSlice: StateCreator = (s } }) - entry = { promise, force: Boolean(options?.force) } + entry = { + promise, + force: Boolean(options?.force), + contextKey, + mutationGeneration: requestMutationGeneration + } inflightProjectDetailRequests.set(cacheKey, entry) return promise }, listLinearProjectIssues: async (projectId, workspaceId, limit = 20, options) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const effectiveLimit = clampLinearIssueListLimit(limit) const cacheKey = linearCollectionCacheKey( workspaceId, @@ -1082,12 +1380,18 @@ export const createLinearSlice: StateCreator = (s } const inflight = inflightProjectIssueRequests.get(cacheKey) - if (inflight && (!options?.force || inflight.force)) { + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === linearMutationGeneration && + (!options?.force || inflight.force) + ) { return inflight.promise } let entry: InflightLinearCollectionRequest const requestCacheGeneration = linearCacheGeneration + const requestMutationGeneration = linearMutationGeneration const promise = linearListProjectIssues( get().settings, projectId, @@ -1100,7 +1404,12 @@ export const createLinearSlice: StateCreator = (s .then((result) => { if ( inflightProjectIssueRequests.get(cacheKey) === entry && - requestCacheGeneration === linearCacheGeneration + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) ) { set((s) => ({ linearProjectIssueCache: evictStaleEntries({ @@ -1113,10 +1422,16 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearProjectIssues failed:', error) - if (isIntegrationCredentialDecryptionError(error)) { + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { void get().checkLinearConnection(true) - } else if (looksLikeAuthError(error)) { - set({ linearStatus: { connected: false, viewer: null } }) } const fallback = get().linearProjectIssueCache[cacheKey]?.data ?? @@ -1136,7 +1451,13 @@ export const createLinearSlice: StateCreator = (s } }) - entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration } + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } inflightProjectIssueRequests.set(cacheKey, entry) return promise }, @@ -1148,6 +1469,7 @@ export const createLinearSlice: StateCreator = (s }, listLinearCustomViews: async (model, limit = 20, workspaceId, options) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) const cached = get().linearCustomViewCache[cacheKey] @@ -1156,19 +1478,30 @@ export const createLinearSlice: StateCreator = (s } const inflight = inflightCustomViewRequests.get(cacheKey) - if (inflight && (!options?.force || inflight.force)) { + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === linearMutationGeneration && + (!options?.force || inflight.force) + ) { return inflight.promise } let entry: InflightLinearCollectionRequest const requestCacheGeneration = linearCacheGeneration + const requestMutationGeneration = linearMutationGeneration const promise = linearListCustomViews(get().settings, model, limit, resolvedWorkspaceId, { force: options?.force }) .then((result) => { if ( inflightCustomViewRequests.get(cacheKey) === entry && - requestCacheGeneration === linearCacheGeneration + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) ) { set((s) => ({ linearCustomViewCache: evictStaleEntries({ @@ -1181,10 +1514,16 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearCustomViews failed:', error) - if (isIntegrationCredentialDecryptionError(error)) { + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { void get().checkLinearConnection(true) - } else if (looksLikeAuthError(error)) { - set({ linearStatus: { connected: false, viewer: null } }) } const fallback = get().linearCustomViewCache[cacheKey]?.data ?? @@ -1197,18 +1536,30 @@ export const createLinearSlice: StateCreator = (s } if ( shouldRefreshStatusAfterRead(resolvedWorkspaceId) && - requestCacheGeneration === linearCacheGeneration + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) ) { void get().checkLinearConnection(true) } }) - entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration } + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } inflightCustomViewRequests.set(cacheKey, entry) return promise }, fetchLinearCustomView: async (viewId, workspaceId, model, options) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const cacheKey = linearCollectionCacheKey(workspaceId, 'custom-view-detail', model, viewId) const cached = get().linearCustomViewDetailCache[cacheKey] if (!options?.force && isFresh(cached)) { @@ -1216,16 +1567,31 @@ export const createLinearSlice: StateCreator = (s } const inflight = inflightCustomViewDetailRequests.get(cacheKey) - if (inflight && (!options?.force || inflight.force)) { + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === linearMutationGeneration && + (!options?.force || inflight.force) + ) { return inflight.promise } let entry: InflightLinearDetailRequest + const requestCacheGeneration = linearCacheGeneration + const requestMutationGeneration = linearMutationGeneration const promise = linearGetCustomView(get().settings, viewId, model, workspaceId, { force: options?.force }) .then((view) => { - if (inflightCustomViewDetailRequests.get(cacheKey) === entry) { + if ( + inflightCustomViewDetailRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { set((s) => ({ linearCustomViewDetailCache: evictStaleEntries({ ...s.linearCustomViewDetailCache, @@ -1237,10 +1603,16 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] fetchLinearCustomView failed:', error) - if (isIntegrationCredentialDecryptionError(error)) { + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { void get().checkLinearConnection(true) - } else if (looksLikeAuthError(error)) { - set({ linearStatus: { connected: false, viewer: null } }) } if (options?.force) { throw error @@ -1257,12 +1629,18 @@ export const createLinearSlice: StateCreator = (s } }) - entry = { promise, force: Boolean(options?.force) } + entry = { + promise, + force: Boolean(options?.force), + contextKey, + mutationGeneration: requestMutationGeneration + } inflightCustomViewDetailRequests.set(cacheKey, entry) return promise }, listLinearCustomViewIssues: async (viewId, workspaceId, limit = 20, options) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const effectiveLimit = clampLinearIssueListLimit(limit) const cacheKey = linearCollectionCacheKey( workspaceId, @@ -1276,12 +1654,18 @@ export const createLinearSlice: StateCreator = (s } const inflight = inflightCustomViewIssueRequests.get(cacheKey) - if (inflight && (!options?.force || inflight.force)) { + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === linearMutationGeneration && + (!options?.force || inflight.force) + ) { return inflight.promise } let entry: InflightLinearCollectionRequest const requestCacheGeneration = linearCacheGeneration + const requestMutationGeneration = linearMutationGeneration const promise = linearListCustomViewIssues( get().settings, viewId, @@ -1294,7 +1678,12 @@ export const createLinearSlice: StateCreator = (s .then((result) => { if ( inflightCustomViewIssueRequests.get(cacheKey) === entry && - requestCacheGeneration === linearCacheGeneration + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) ) { set((s) => ({ linearCustomViewIssueCache: evictStaleEntries({ @@ -1307,10 +1696,16 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearCustomViewIssues failed:', error) - if (isIntegrationCredentialDecryptionError(error)) { + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { void get().checkLinearConnection(true) - } else if (looksLikeAuthError(error)) { - set({ linearStatus: { connected: false, viewer: null } }) } const fallback = get().linearCustomViewIssueCache[cacheKey]?.data ?? @@ -1330,12 +1725,19 @@ export const createLinearSlice: StateCreator = (s } }) - entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration } + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } inflightCustomViewIssueRequests.set(cacheKey, entry) return promise }, listLinearCustomViewProjects: async (viewId, workspaceId, limit = 20, options) => { + const contextKey = getProviderRuntimeContextKey(get().settings) const cacheKey = linearCollectionCacheKey(workspaceId, 'custom-view-projects', viewId, limit) const cached = get().linearCustomViewProjectCache[cacheKey] if (!options?.force && isFresh(cached)) { @@ -1343,19 +1745,30 @@ export const createLinearSlice: StateCreator = (s } const inflight = inflightCustomViewProjectRequests.get(cacheKey) - if (inflight && (!options?.force || inflight.force)) { + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === linearMutationGeneration && + (!options?.force || inflight.force) + ) { return inflight.promise } let entry: InflightLinearCollectionRequest const requestCacheGeneration = linearCacheGeneration + const requestMutationGeneration = linearMutationGeneration const promise = linearListCustomViewProjects(get().settings, viewId, limit, workspaceId, { force: options?.force }) .then((result) => { if ( inflightCustomViewProjectRequests.get(cacheKey) === entry && - requestCacheGeneration === linearCacheGeneration + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) ) { set((s) => ({ linearCustomViewProjectCache: evictStaleEntries({ @@ -1368,10 +1781,16 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearCustomViewProjects failed:', error) - if (isIntegrationCredentialDecryptionError(error)) { + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings + ) + ) { void get().checkLinearConnection(true) - } else if (looksLikeAuthError(error)) { - set({ linearStatus: { connected: false, viewer: null } }) } const fallback = get().linearCustomViewProjectCache[cacheKey]?.data ?? @@ -1384,7 +1803,13 @@ export const createLinearSlice: StateCreator = (s } }) - entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration } + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } inflightCustomViewProjectRequests.set(cacheKey, entry) return promise }, diff --git a/src/renderer/src/store/slices/preflight.test.ts b/src/renderer/src/store/slices/preflight.test.ts index 99e59a5069b..571386127e7 100644 --- a/src/renderer/src/store/slices/preflight.test.ts +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -6,6 +6,17 @@ import type { AppState } from '../types' import { createPreflightSlice } from './preflight' const preflightCheck = vi.fn() +const callRuntimeRpc = vi.fn() + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args), + getActiveRuntimeTarget: ( + settings?: { activeRuntimeEnvironmentId?: string | null } | null + ): { kind: 'local' } | { kind: 'environment'; environmentId: string } => { + const environmentId = settings?.activeRuntimeEnvironmentId?.trim() + return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } + } +})) globalThis.window = { api: { @@ -33,6 +44,11 @@ function createTestStore() { ) } +function resetPreflightMocks(): void { + preflightCheck.mockReset() + callRuntimeRpc.mockReset() +} + function makeStatus(glabInstalled: boolean): PreflightStatus { return { git: { installed: true }, @@ -86,7 +102,7 @@ function deferred() { describe('createPreflightSlice', () => { it('dedupes concurrent non-forced checks', async () => { - preflightCheck.mockReset() + resetPreflightMocks() const pending = deferred() preflightCheck.mockReturnValueOnce(pending.promise) const store = createTestStore() @@ -104,7 +120,7 @@ describe('createPreflightSlice', () => { }) it('lets forced checks bypass non-forced dedupe and win stale races', async () => { - preflightCheck.mockReset() + resetPreflightMocks() const stale = deferred() const fresh = deferred() preflightCheck.mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise) @@ -125,7 +141,7 @@ describe('createPreflightSlice', () => { }) it('dedupes lazy checks onto an in-flight forced refresh', async () => { - preflightCheck.mockReset() + resetPreflightMocks() const fresh = deferred() preflightCheck.mockReturnValueOnce(fresh.promise) const store = createTestStore() @@ -141,7 +157,7 @@ describe('createPreflightSlice', () => { }) it('checks integrations inside the active WSL worktree distro', async () => { - preflightCheck.mockReset() + resetPreflightMocks() preflightCheck.mockResolvedValueOnce(makeStatus(true)) const store = createTestStore() store.setState({ @@ -170,7 +186,7 @@ describe('createPreflightSlice', () => { }) it('keeps preflight request dedupe scoped by WSL distro context', async () => { - preflightCheck.mockReset() + resetPreflightMocks() const ubuntu = deferred() const debian = deferred() preflightCheck.mockReturnValueOnce(ubuntu.promise).mockReturnValueOnce(debian.promise) @@ -196,8 +212,46 @@ describe('createPreflightSlice', () => { expect(store.getState().preflightStatus?.glab?.installed).toBe(true) }) + it('checks integrations through the active runtime environment', async () => { + resetPreflightMocks() + const firstRuntime = deferred() + const secondRuntime = deferred() + callRuntimeRpc + .mockReturnValueOnce(firstRuntime.promise) + .mockReturnValueOnce(secondRuntime.promise) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'runtime-1' } + } as Partial) + + const first = store.getState().refreshPreflightStatus() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'runtime-2' } + } as Partial) + const second = store.getState().refreshPreflightStatus() + + expect(preflightCheck).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 1, + { kind: 'environment', environmentId: 'runtime-1' }, + 'preflight.check', + {} + ) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 2, + { kind: 'environment', environmentId: 'runtime-2' }, + 'preflight.check', + {} + ) + firstRuntime.resolve(makeStatus(false)) + secondRuntime.resolve(makeStatus(true)) + await Promise.all([first, second]) + expect(store.getState().preflightStatusContextKey).toBe('runtime:runtime-2#0') + expect(store.getState().preflightStatus?.glab?.installed).toBe(true) + }) + it('clears checked status immediately when refreshing a different local context', async () => { - preflightCheck.mockReset() + resetPreflightMocks() const host = deferred() const wsl = deferred() preflightCheck.mockReturnValueOnce(host.promise).mockReturnValueOnce(wsl.promise) diff --git a/src/renderer/src/store/slices/preflight.ts b/src/renderer/src/store/slices/preflight.ts index 15bff1f5be3..162c91083b9 100644 --- a/src/renderer/src/store/slices/preflight.ts +++ b/src/renderer/src/store/slices/preflight.ts @@ -1,6 +1,7 @@ import type { StateCreator } from 'zustand' import type { PreflightStatus } from '../../../../preload/api-types' import type { AppState } from '../types' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getLocalPreflightContext, localPreflightContextKey, @@ -29,12 +30,15 @@ function buildPreflightArgs( force: boolean, context: LocalPreflightContext ): { force?: boolean; wslDistro?: string | null; wslDefault?: boolean } | undefined { - if (!force && !context) { + const wslDistro = context?.wslDistro + const wslDefault = context?.wslDefault === true + if (!force && !wslDistro && !wslDefault) { return undefined } return { ...(force ? { force: true } : {}), - ...context + ...(wslDistro ? { wslDistro } : {}), + ...(wslDefault ? { wslDefault: true } : {}) } } @@ -61,6 +65,8 @@ export const createPreflightSlice: StateCreator(runtimeTarget, 'preflight.check', force ? { force } : {}) + : window.api.preflight.check(preflightArgs) + ) .then((status) => { if (requestId !== latestPreflightRequestId) { return diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index eb7b33cb9e6..b99a26bbb97 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -15,6 +15,7 @@ import { normalizeTaskProviderSettings } from '../../../../shared/task-providers import { normalizeOpenInApplications } from '../../../../shared/open-in-applications' import { createSettingsSearchState, type SettingsSearchState } from './settings-search-state' import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' +import { bumpProviderRuntimeSessionGeneration } from '@/lib/provider-runtime-context' import { normalizeUiLanguage } from '../../../../shared/ui-language' import { translate } from '@/i18n/i18n' @@ -129,6 +130,7 @@ export const createSettingsSlice: StateCreator const nextSettings = await window.api.settings.set({ activeRuntimeEnvironmentId: nextId }) + bumpProviderRuntimeSessionGeneration() set((s) => ({ // Why: in the multi-host model this is a focus/default-host change, // not a teardown boundary. Existing host-owned sessions stay alive.