diff --git a/src/renderer/src/components/linear-project-view-surfaces.tsx b/src/renderer/src/components/linear-project-view-surfaces.tsx index 11525ed142d..159caf943af 100644 --- a/src/renderer/src/components/linear-project-view-surfaces.tsx +++ b/src/renderer/src/components/linear-project-view-surfaces.tsx @@ -10,6 +10,7 @@ import type { LinearProjectSummary } from '../../../shared/linear/project-types' import type { LinearWorkspaceError } from '../../../shared/linear/workspace-types' +import { isIntegrationCredentialDecryptionError } from '../../../shared/integration-credential-errors' import { LinearCustomViewTableContent, type LinearCustomViewTableContentProps @@ -59,7 +60,13 @@ export function LinearCollectionNotice({
{errors.map((error) => ( - {error.workspaceName ?? error.workspaceId}: {error.message} + {error.workspaceName ?? error.workspaceId}:{' '} + {isIntegrationCredentialDecryptionError(error.message) + ? translate( + 'auto.components.linear.project.view.surfaces.linearLoginUnreadable', + 'Unable to read saved Linear login. Reconnect it in Settings.' + ) + : error.message} ))}
diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceSourceResultList.linear-read-error.test.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceSourceResultList.linear-read-error.test.tsx new file mode 100644 index 00000000000..88e767ef4a3 --- /dev/null +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceSourceResultList.linear-read-error.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +import React from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors' +import type { LinearIssue } from '../../../../shared/linear/issue-types' +import type { LinearConnectionStatus } from '../../../../shared/linear/workspace-types' +import type { Repo } from '../../../../shared/repo-types' +import { Command } from '@/components/ui/command' +import { + referenceLinearIssue, + referenceRepo +} from '../sidebar/workspace-reference-fixtures.test-support' +import { SmartWorkspaceSourceResultList } from './SmartWorkspaceSourceResultList' +import { useWorkItemSourceSearch } from './use-work-item-source-search' + +const mocks = vi.hoisted(() => { + // Host-wide status carries no error: the composer must show the read's own failure. + const linearStatus: LinearConnectionStatus = { + connected: true, + viewer: null, + selectedWorkspaceId: 'workspace-1' + } + return { + state: { + checkLinearConnection: vi.fn(), + linearStatus, + linearStatusChecked: true, + settings: null, + preflightStatus: null, + preflightStatusChecked: true, + preflightStatusContextKey: 'test', + refreshPreflightStatus: vi.fn(), + searchLinearIssues: vi.fn<(query: string) => Promise>() + } + } +}) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state) +})) +vi.mock('@/lib/local-preflight-context', () => ({ + getLocalPreflightContext: () => ({}), + localPreflightContextKey: () => 'test' +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('./use-jira-source-connection', () => ({ + useJiraSourceConnection: () => ({ status: null, loaded: false }) +})) +vi.mock('./use-jira-url-source', () => ({ useJiraUrlSource: () => ({ intent: false }) })) +vi.mock('../use-jira-project-inventory', () => ({ + useJiraProjectInventory: () => ({ projects: [], connected: true, error: null, loading: false }) +})) + +const repo: Repo = { ...referenceRepo, executionHostId: 'runtime:env-1' } +// Stable identity: search effects depend on the repos array. +const repos = [repo] + +function LinearPicker({ value }: { value: string }): React.JSX.Element { + const controller = useWorkItemSourceSearch({ + repos, + selectedRepo: repo, + repoId: repo.id, + value, + branchesEnabled: false, + repoBackedSourcesDisabled: true + }) + return ( + + + + {}} /> + + ) +} + +describe('composer Linear read errors', () => { + afterEach(cleanup) + + it('shows the read credential error on the Linear tab only, and a successful search clears it', async () => { + mocks.state.searchLinearIssues.mockImplementation(async (query) => { + if (query === 'broken') { + throw new Error(credentialDecryptionMessage('Linear')) + } + return [referenceLinearIssue()] + }) + const message = 'Reconnect Linear' + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: 'Linear tab' })) + + await waitFor(() => + expect(screen.getByRole('status').textContent).toBe( + `${message}Unable to read saved Linear login. Reconnect it in Settings.` + ) + ) + + // The status refresh after the failure re-runs the same read; the error must stay up. + mocks.state.linearStatus = { + ...mocks.state.linearStatus, + credentialError: credentialDecryptionMessage('Linear') + } + rerender() + expect(screen.getByText(message)).toBeTruthy() + await waitFor(() => expect(mocks.state.searchLinearIssues).toHaveBeenCalledTimes(2)) + expect(screen.getByText(message)).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Smart tab' })) + expect(screen.queryByText(message)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Linear tab' })) + + rerender() + await waitFor(() => expect(screen.getByText(/Task/)).toBeTruthy()) + expect(screen.queryByText(message)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceSourceResultList.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceSourceResultList.tsx index f0c62a5f14e..ab87a547af2 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceSourceResultList.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceSourceResultList.tsx @@ -28,6 +28,7 @@ export function SmartWorkspaceSourceResultList({ | 'searchResultRows' | 'linearStatusChecked' | 'linearStatus' + | 'linearReadError' | 'showJiraSiteContext' | 'jiraConnectionStatus' | 'reserveLinearLoadingResults' @@ -53,8 +54,11 @@ export function SmartWorkspaceSourceResultList({ showJiraSiteContext, jiraConnectionStatus, reserveLinearLoadingResults, - showLinearUrlLoadingFeedback + showLinearUrlLoadingFeedback, + linearReadError } = model + // Why: only the Linear tab speaks for Linear; Smart lists what the other sources return. + const visibleLinearReadError = mode === 'linear' ? linearReadError : null return ( <> {mode === 'gitlab' ? ( @@ -117,6 +121,21 @@ export function SmartWorkspaceSourceResultList({
))}
+ ) : searchResultRows.length === 0 && visibleLinearReadError && !jiraSource.intent ? ( +
+

+ {translate( + 'auto.components.new.workspace.SmartWorkspaceNameField.linearLoginUnreadableTitle', + 'Reconnect Linear' + )} +

+

+ {translate( + 'auto.components.new.workspace.SmartWorkspaceNameField.linearLoginUnreadableBody', + 'Unable to read saved Linear login. Reconnect it in Settings.' + )} +

+
) : searchResultRows.length === 0 && !typedTextActionRow ? (
{jiraSource.intent diff --git a/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts b/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts index efa54264880..52761c5713a 100644 --- a/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts +++ b/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts @@ -4,6 +4,10 @@ import { searchRuntimeRepoBaseRefDetails } from '@/runtime/runtime-repo-client' import { lookupLinearIssueUrl } from '@/lib/linear-issue-url-lookup' import { searchJiraIssuesByKeyOrText } from '@/components/jira-key-or-text-search' import { linearWorkspaceScopeSignature } from '../../../../shared/linear/workspace-types' +import { + credentialDecryptionMessage, + isIntegrationCredentialDecryptionError +} from '../../../../shared/integration-credential-errors' import { getSmartWorkspaceLinearSearchQuery } from '../../../../shared/new-workspace/smart-workspace-linear-intent' import type { parseBoundedSmartWorkspaceLinearIssueUrlIntent } from '../../../../shared/new-workspace/smart-workspace-linear-intent' import { RESULT_LIMIT } from './smart-workspace-name-field-model' @@ -51,6 +55,7 @@ export function useSmartWorkspaceSecondarySearches({ setLinearIssues, setLinearLoading, setSettledLinearUrlQuery, + setLinearReadError, jiraSourceContext, jiraConnectionStatus, jiraProject, @@ -74,6 +79,7 @@ export function useSmartWorkspaceSecondarySearches({ listLinearIssues, searchLinearIssues } + const failedLinearReadRef = useRef(null) const linearConnected = linearStatus.connected === true const linearScopeSignature = linearWorkspaceScopeSignature(linearStatus) const branchSearchRequest = useMemo( @@ -150,16 +156,27 @@ export function useSmartWorkspaceSecondarySearches({ ]) useEffect(() => { - if (disabled || !shouldQueryLinear || !linearConnected) { + const readKey = [linearQuery, linearSourceContext, linearWorkspaceIdOverride] as const + // Why: the status refresh after a failure re-runs this same read; don't blink its error. + const rereadOfFailure = + failedLinearReadRef.current?.every((part, index) => part === readKey[index]) === true + const canQuery = !disabled && shouldQueryLinear && linearConnected + if (!rereadOfFailure || !canQuery) { + failedLinearReadRef.current = null + setLinearReadError(null) + } + if (!canQuery) { setLinearIssues([]) setLinearLoading(false) setSettledLinearUrlQuery(null) return } let stale = false - setLinearLoading(true) const trimmed = linearQuery.trim() - setSettledLinearUrlQuery(null) + if (!rereadOfFailure) { + setLinearLoading(true) + setSettledLinearUrlQuery(null) + } // Why: empty-query list must not briefly paint the previous non-empty result set. if (trimmed === '') { setLinearIssues([]) @@ -169,7 +186,11 @@ export function useSmartWorkspaceSecondarySearches({ intent: linearUrlIntent, knownStatus: linearStatusRef.current, sourceContext: linearSourceContext, - fetchLinearIssue: linearReadMethodsRef.current.fetchLinearIssue + fetchLinearIssue: (identifier, workspaceId, options) => + linearReadMethodsRef.current.fetchLinearIssue(identifier, workspaceId, { + ...options, + rejectCredentialError: true + }) }).then((issue) => (issue ? [issue] : [])) : trimmed ? linearReadMethodsRef.current.searchLinearIssues( @@ -196,12 +217,18 @@ export function useSmartWorkspaceSecondarySearches({ void request .then((issues) => { if (!stale) { + failedLinearReadRef.current = null + setLinearReadError(null) setLinearIssues(issues) } }) - .catch(() => { + .catch((error) => { if (!stale) { setLinearIssues([]) + // Why: the read reports its own host's failure; host-wide status may describe another host. + const credentialError = isIntegrationCredentialDecryptionError(error) + failedLinearReadRef.current = credentialError ? readKey : null + setLinearReadError(credentialError ? credentialDecryptionMessage('Linear') : null) } }) .finally(() => { diff --git a/src/renderer/src/components/new-workspace/use-work-item-source-state.ts b/src/renderer/src/components/new-workspace/use-work-item-source-state.ts index 05641de4ed6..7aa8f798ba4 100644 --- a/src/renderer/src/components/new-workspace/use-work-item-source-state.ts +++ b/src/renderer/src/components/new-workspace/use-work-item-source-state.ts @@ -30,6 +30,7 @@ export function useWorkItemSourceState(textOnly: boolean, value: string) { null ) const [settledLinearUrlQuery, setSettledLinearUrlQuery] = useState(null) + const [linearReadError, setLinearReadError] = useState(null) const [jiraLoading, setJiraLoading] = useState(false) const [commandValue, setCommandValue] = useState('') const repoSlugCacheRef = useRef>(new Map()) @@ -67,6 +68,8 @@ export function useWorkItemSourceState(textOnly: boolean, value: string) { setLinearUrlLoadingFeedbackQuery, settledLinearUrlQuery, setSettledLinearUrlQuery, + linearReadError, + setLinearReadError, jiraLoading, setJiraLoading, commandValue, diff --git a/src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.tsx b/src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.tsx index 7c5fb9fa48c..9aff1ee1f2e 100644 --- a/src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.tsx @@ -258,7 +258,10 @@ export function WorkspaceAttachmentSearch({ {query.trim() ? (
props.disabled || controller.isQueryStale} diff --git a/src/renderer/src/components/sidebar/WorkspaceAttachmentSourceStatus.tsx b/src/renderer/src/components/sidebar/WorkspaceAttachmentSourceStatus.tsx index c8239f8f950..390f320b437 100644 --- a/src/renderer/src/components/sidebar/WorkspaceAttachmentSourceStatus.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceAttachmentSourceStatus.tsx @@ -17,7 +17,9 @@ export function WorkspaceAttachmentSourceStatus({ error: string | null onRetry: () => void }): React.JSX.Element { - let message = + // Why: on the Linear tab the empty result list explains an unreadable login. + const linearLoginUnreadable = controller.mode === 'linear' && Boolean(controller.linearReadError) + let message: string | null = error ?? translate('workspace.links.inlineSearchHint', 'Select a result to attach it.') let retry: (() => void) | null = null if (!error && taskUrlProvider === 'jira') { @@ -48,6 +50,9 @@ export function WorkspaceAttachmentSourceStatus({ 'Connect Linear in Settings to link this issue.' ) retry = onRetry + } else if (linearLoginUnreadable) { + // Why: Retry re-reads once Linear is reconnected. + retry = onRetry } else if (controller.unresolvedLinearUrlIntent || controller.linearLoading) { message = translate('workspace.links.loadingLinear', 'Loading Linear issue…') } else { @@ -58,13 +63,17 @@ export function WorkspaceAttachmentSourceStatus({ retry = onRetry } } + // Why: keep the row (and its action) so the sibling Reference type button doesn't shift. + if (!error && linearLoginUnreadable && taskUrlProvider !== 'jira') { + message = null + } return (
- {message} + {message ? {message} : null} {retry ? (