diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 47040268575..6166ecdd42e 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -79,6 +79,7 @@ import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' import type { GitLabWorkItem } from '../../../../shared/gitlab-types' import type { JiraIssue, JiraSite } from '../../../../shared/jira-types' import type { LinearIssue } from '../../../../shared/linear/issue-types' +import { linearWorkspaceScopeSignature } from '../../../../shared/linear/workspace-types' import type { BaseRefSearchResult } from '../../../../shared/repo-types' import { resolveSmartWorkspaceCommandValue } from './smart-workspace-command-value' import { isComposerFieldToFieldFocus } from './smart-workspace-source-popover-focus' @@ -425,6 +426,14 @@ export default function SmartWorkspaceNameField({ const showJiraSiteContext = mode === 'jira' && jiraConnectionStatus?.selectedSiteId === 'all' const jiraStatusId = React.useId() const linearStatusId = React.useId() + // Read the latest metadata for URL resolution without making the search effect depend on object identity. + const linearStatusRef = useRef(linearStatus) + linearStatusRef.current = linearStatus + // Store action references can change with wiring; reads should only rerun for query/scope changes. + const linearReadMethodsRef = useRef({ fetchLinearIssue, listLinearIssues, searchLinearIssues }) + linearReadMethodsRef.current = { fetchLinearIssue, listLinearIssues, searchLinearIssues } + const linearConnected = linearStatus.connected === true + const linearScopeSignature = linearWorkspaceScopeSignature(linearStatus) useEffect(() => { onActiveSourceModeChange?.(mode) @@ -1018,7 +1027,7 @@ export default function SmartWorkspaceNameField({ }, [branchSearchRequest, selectedRepoOwnerSettings]) useEffect(() => { - if (disabled || !shouldQueryLinear || !linearStatus.connected) { + if (disabled || !shouldQueryLinear || !linearConnected) { setLinearIssues([]) setLinearLoading(false) setSettledLinearUrlQuery(null) @@ -1035,18 +1044,24 @@ export default function SmartWorkspaceNameField({ const request = linearUrlIntent ? lookupLinearIssueUrl({ intent: linearUrlIntent, - knownStatus: linearStatus, + knownStatus: linearStatusRef.current, sourceContext: linearSourceContext, - fetchLinearIssue + fetchLinearIssue: linearReadMethodsRef.current.fetchLinearIssue }).then((issue) => (issue ? [issue] : [])) : trimmed - ? searchLinearIssues(getSmartWorkspaceLinearSearchQuery(trimmed), RESULT_LIMIT, { - sourceContext: linearSourceContext - }) - : listLinearIssues( - { kind: 'list', filter: 'assigned', limit: RESULT_LIMIT }, - { sourceContext: linearSourceContext } - ).then((result) => result.items) + ? linearReadMethodsRef.current.searchLinearIssues( + getSmartWorkspaceLinearSearchQuery(trimmed), + RESULT_LIMIT, + { + sourceContext: linearSourceContext + } + ) + : linearReadMethodsRef.current + .listLinearIssues( + { kind: 'list', filter: 'assigned', limit: RESULT_LIMIT }, + { sourceContext: linearSourceContext } + ) + .then((result) => result.items) void request .then((issues) => { if (!stale) { @@ -1068,8 +1083,15 @@ export default function SmartWorkspaceNameField({ stale = true } // Why: list/search are stable store methods; depending on them would refetch on unrelated store writes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [disabled, linearQuery, linearSourceContext, linearStatus, linearUrlIntent, shouldQueryLinear]) + }, [ + disabled, + linearConnected, + linearQuery, + linearScopeSignature, + linearSourceContext, + linearUrlIntent, + shouldQueryLinear + ]) useEffect(() => { if (!shouldQueryJira || !jiraSourceContext || !jiraSearchJql) { diff --git a/src/renderer/src/store/slices/linear.test.ts b/src/renderer/src/store/slices/linear.test.ts index 8aaf54e720a..ac3cad34fcc 100644 --- a/src/renderer/src/store/slices/linear.test.ts +++ b/src/renderer/src/store/slices/linear.test.ts @@ -86,6 +86,40 @@ describe('createLinearSlice', () => { expect(store.getState().linearStatusChecked).toBe(true) }) + it('preserves status identity when a connection probe leaves its scope unchanged', async () => { + const store = createTestStore() + const currentStatus: LinearConnectionStatus = { + connected: true, + viewer: { + displayName: 'Test User', + email: 'test@example.com', + organizationName: 'Test Org' + }, + selectedWorkspaceId: 'workspace-1', + activeWorkspaceId: 'workspace-1', + workspaces: [ + { + id: 'workspace-1', + organizationId: 'workspace-1', + organizationName: 'Test Org', + displayName: 'Test User', + email: 'test@example.com' + } + ] + } + store.setState({ + linearStatus: currentStatus, + linearStatusChecked: true, + linearStatusContextKey: 'local#0' + }) + linearTestConnection.mockResolvedValueOnce({ ok: true, viewer: currentStatus.viewer }) + linearStatus.mockResolvedValueOnce({ ...currentStatus, viewer: { ...currentStatus.viewer } }) + + await store.getState().testLinearConnection() + + expect(store.getState().linearStatus).toBe(currentStatus) + }) + it('ignores stale forced connection checks when a newer forced check finishes first', async () => { const staleCheck = deferred() const freshCheck = deferred() diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 0f86363b6ef..605b2c8edd5 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -236,6 +236,7 @@ function linearWorkspaceSignature(workspace: LinearWorkspace): string { ].join('\u001f') } +/** Cache-invalidation key: broader than `linearWorkspaceScopeSignature`, hashes full viewer and workspace metadata. */ function linearStatusScopeSignature(status: LinearConnectionStatus): string { return JSON.stringify({ connected: status.connected, @@ -607,7 +608,7 @@ export const createLinearSlice: StateCreator = (s if (inflightStatusRequest && !force && inflightStatusRequest.contextKey === contextKey) { return inflightStatusRequest.promise } - if (get().linearStatusContextKey !== contextKey) { + if (get().linearStatusContextKey !== contextKey && get().linearStatusChecked) { set({ linearStatusChecked: false }) } @@ -732,12 +733,9 @@ export const createLinearSlice: StateCreator = (s linearStatusChecked: true, linearStatusContextKey: contextKey }) - } else { - set({ - linearStatus: status, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) + } else if (!get().linearStatusChecked || get().linearStatusContextKey !== contextKey) { + // Preserve the status reference when the probe did not change scope. + set({ linearStatusChecked: true, linearStatusContextKey: contextKey }) } } return result diff --git a/src/shared/linear/workspace-types.test.ts b/src/shared/linear/workspace-types.test.ts new file mode 100644 index 00000000000..6f6174d0870 --- /dev/null +++ b/src/shared/linear/workspace-types.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { linearWorkspaceScopeSignature, type LinearConnectionStatus } from './workspace-types' + +describe('linearWorkspaceScopeSignature', () => { + it('ignores status metadata and workspace ordering', () => { + const first: LinearConnectionStatus = { + connected: true, + viewer: { displayName: 'Ada', email: 'ada@example.com', organizationName: 'Alpha' }, + selectedWorkspaceId: 'workspace-1', + workspaces: [ + { + id: 'workspace-1', + organizationId: 'org-1', + organizationName: 'Alpha', + displayName: 'Ada', + email: 'ada@example.com' + }, + { + id: 'workspace-2', + organizationId: 'org-2', + organizationName: 'Beta', + displayName: 'Ada', + email: 'ada@example.com' + } + ] + } + const second: LinearConnectionStatus = { + ...first, + viewer: { ...first.viewer!, organizationName: 'Renamed' }, + workspaces: [ + { ...first.workspaces![1], organizationName: 'Renamed Beta' }, + { ...first.workspaces![0], organizationName: 'Renamed Alpha' } + ] + } + + expect(linearWorkspaceScopeSignature(second)).toBe(linearWorkspaceScopeSignature(first)) + }) + + it('changes when the selected workspace changes', () => { + const first: LinearConnectionStatus = { + connected: true, + viewer: null, + selectedWorkspaceId: 'workspace-1' + } + const second = { ...first, selectedWorkspaceId: 'workspace-2' } + + expect(linearWorkspaceScopeSignature(second)).not.toBe(linearWorkspaceScopeSignature(first)) + }) + + it('changes when the active workspace changes while all workspaces are selected', () => { + const first: LinearConnectionStatus = { + connected: true, + viewer: null, + selectedWorkspaceId: 'all', + activeWorkspaceId: 'workspace-1' + } + const second = { ...first, activeWorkspaceId: 'workspace-2' } + + expect(linearWorkspaceScopeSignature(second)).not.toBe(linearWorkspaceScopeSignature(first)) + }) + + it('changes when a workspace credential revision changes', () => { + const first: LinearConnectionStatus = { + connected: true, + viewer: null, + workspaces: [ + { + id: 'workspace-1', + organizationId: 'org-1', + organizationName: 'Alpha', + displayName: 'Ada', + email: 'ada@example.com', + credentialRevision: 1 + } + ] + } + const second: LinearConnectionStatus = { + ...first, + workspaces: [{ ...first.workspaces![0], credentialRevision: 2 }] + } + + expect(linearWorkspaceScopeSignature(second)).not.toBe(linearWorkspaceScopeSignature(first)) + }) + + it('changes when a workspace or viewer organization url key changes', () => { + const first: LinearConnectionStatus = { + connected: true, + viewer: { + displayName: 'Ada', + email: 'ada@example.com', + organizationName: 'Alpha', + organizationUrlKey: 'alpha' + }, + workspaces: [ + { + id: 'workspace-1', + organizationId: 'org-1', + organizationName: 'Alpha', + displayName: 'Ada', + email: 'ada@example.com', + organizationUrlKey: 'alpha' + } + ] + } + const renamedWorkspaceKey: LinearConnectionStatus = { + ...first, + workspaces: [{ ...first.workspaces![0], organizationUrlKey: 'alpha-renamed' }] + } + const renamedViewerKey: LinearConnectionStatus = { + ...first, + viewer: { ...first.viewer!, organizationUrlKey: 'alpha-renamed' } + } + + expect(linearWorkspaceScopeSignature(renamedWorkspaceKey)).not.toBe( + linearWorkspaceScopeSignature(first) + ) + expect(linearWorkspaceScopeSignature(renamedViewerKey)).not.toBe( + linearWorkspaceScopeSignature(first) + ) + }) +}) diff --git a/src/shared/linear/workspace-types.ts b/src/shared/linear/workspace-types.ts index 802842bb72f..508fd980a1e 100644 --- a/src/shared/linear/workspace-types.ts +++ b/src/shared/linear/workspace-types.ts @@ -41,6 +41,40 @@ export type LinearConnectionStatus = { credentialError?: string } +/** + * Stable dependency key for Linear reads: only the fields that change what a read returns. + * Not interchangeable with the store's `linearStatusScopeSignature`, which hashes full + * viewer and workspace metadata for cache invalidation and so churns on read-irrelevant edits. + */ +export function linearWorkspaceScopeSignature( + status: Pick< + LinearConnectionStatus, + | 'connected' + | 'credentialError' + | 'activeWorkspaceId' + | 'selectedWorkspaceId' + | 'workspaces' + | 'viewer' + > +): string { + return JSON.stringify({ + connected: status.connected === true, + credentialError: status.credentialError ?? null, + workspaceId: status.selectedWorkspaceId ?? status.activeWorkspaceId ?? null, + // Why: under 'all', URL lookup still falls back to the active workspace, so it must key reads too. + activeWorkspaceId: status.activeWorkspaceId ?? null, + // Why: URL resolution routes by organizationUrlKey, and credentialRevision changes what a read returns. + viewerOrganizationUrlKey: status.viewer?.organizationUrlKey ?? null, + workspaces: (status.workspaces ?? []) + .map((workspace) => + [workspace.id, workspace.organizationUrlKey ?? '', workspace.credentialRevision ?? 0].join( + '\u001f' + ) + ) + .sort() + }) +} + export type LinearWorkflowState = { id: string name: string