From f474949f2043834009be56e896adcf5da89e36d4 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:03:07 +0800 Subject: [PATCH 1/2] feat(github): support issue assignee editing Pre-commit hook ran. Total eslint: 3, total circular: 0 --- .../ChatPanel/panels/GitHubIssuePanelView.tsx | 4 +- .../useGitHubIssueAssigneeMutations.ts | 31 +-- .../SourceControlMainContent/index.tsx | 16 +- .../content/SourceControlMainPane.tsx | 16 +- .../renderers/githubIssueDetail.tsx | 4 +- src/modules/shared/githubIssueAssignees.ts | 34 +++ .../hooks/useGitHubIssueDetailState.test.ts | 83 ++++++- .../shared/hooks/useGitHubIssueDetailState.ts | 223 +++++++++++++++++- 8 files changed, 373 insertions(+), 38 deletions(-) create mode 100644 src/modules/shared/githubIssueAssignees.ts diff --git a/src/engines/ChatPanel/panels/GitHubIssuePanelView.tsx b/src/engines/ChatPanel/panels/GitHubIssuePanelView.tsx index f96579be8..39698fa37 100644 --- a/src/engines/ChatPanel/panels/GitHubIssuePanelView.tsx +++ b/src/engines/ChatPanel/panels/GitHubIssuePanelView.tsx @@ -10,7 +10,8 @@ export function GitHubIssuePanelView({ }: { detail: GitHubIssueDetailTabData; }): React.ReactNode { - const { selectedState, interaction } = useGitHubIssueDetailState(detail); + const { selectedState, interaction, assigneeConfig } = + useGitHubIssueDetailState(detail); if (!selectedState.issue) { return ( @@ -35,6 +36,7 @@ export function GitHubIssuePanelView({ timeline={selectedState.timeline} timelineLoading={selectedState.timelineLoading} interaction={interaction} + assigneeConfig={assigneeConfig} /> ); } diff --git a/src/modules/MainApp/WorkManagement/useGitHubIssueAssigneeMutations.ts b/src/modules/MainApp/WorkManagement/useGitHubIssueAssigneeMutations.ts index d0069095e..79c5a0d6b 100644 --- a/src/modules/MainApp/WorkManagement/useGitHubIssueAssigneeMutations.ts +++ b/src/modules/MainApp/WorkManagement/useGitHubIssueAssigneeMutations.ts @@ -2,6 +2,10 @@ import { useCallback, useRef, useState } from "react"; import type { GitHubIssue, GitHubIssueUser } from "@src/api/tauri/github"; import Message from "@src/components/Message"; +import { + issueHasAssigneeLogins, + resolveGitHubAssigneeUsers, +} from "@src/modules/shared/githubIssueAssignees"; import { updateCachedClosedIssues, updateCachedOpenIssues, @@ -59,31 +63,14 @@ export function resolveIssueAssigneeUsers( assignableUsers: GitHubIssueUser[], assigneeLogins: string[] ): GitHubIssueUser[] { - const usersByLogin = new Map( - [...item.rawIssue.assignees, ...assignableUsers].map((user) => [ - user.login.toLowerCase(), - user, - ]) - ); - return assigneeLogins.map( - (login) => - usersByLogin.get(login.toLowerCase()) ?? { login, avatar_url: "" } + return resolveGitHubAssigneeUsers( + item.rawIssue.assignees, + assignableUsers, + assigneeLogins ); } -export function issueHasAssigneeLogins( - issue: GitHubIssue, - assigneeLogins: string[] -): boolean { - const actual = issue.assignees - .map((assignee) => assignee.login.toLowerCase()) - .sort(); - const expected = assigneeLogins.map((login) => login.toLowerCase()).sort(); - return ( - actual.length === expected.length && - actual.every((login, index) => login === expected[index]) - ); -} +export { issueHasAssigneeLogins }; export function useGitHubIssueAssigneeMutations({ repoSources, diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/SourceControlMainContent/index.tsx b/src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/SourceControlMainContent/index.tsx index 0c1297c8a..b14ba3121 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/SourceControlMainContent/index.tsx +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/SourceControlMainContent/index.tsx @@ -92,12 +92,15 @@ const SourceControlMainContent: React.FC = ({ emptyFocusActions, }) => { const scopeKey = workstationRepoScopeKey(repoId, repoPath); - const { selectedState: selectedIssueState, interaction } = - useGitHubIssueDetailState({ - repoPath: repoPath ?? "", - repoId, - stateScopeKey: scopeKey, - }); + const { + selectedState: selectedIssueState, + interaction, + assigneeConfig, + } = useGitHubIssueDetailState({ + repoPath: repoPath ?? "", + repoId, + stateScopeKey: scopeKey, + }); // `historySelection` keeps a stable reference across renders (it comes from // the persisted tab payload), so memoizing on it directly gives a stable @@ -144,6 +147,7 @@ const SourceControlMainContent: React.FC = ({ timeline={selectedIssueState.timeline} timelineLoading={selectedIssueState.timelineLoading} interaction={interaction} + assigneeConfig={assigneeConfig} /> ); diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/SourceControlMainPane.tsx b/src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/SourceControlMainPane.tsx index f4c25a119..3ef40078f 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/SourceControlMainPane.tsx +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/SourceControlMainPane.tsx @@ -66,12 +66,15 @@ const SourceControlMainPane: React.FC = ({ onGitDiffUnsavedChange, }) => { const scopeKey = workstationRepoScopeKey(repoId, repoPath); - const { selectedState: selectedIssueState, interaction } = - useGitHubIssueDetailState({ - repoPath, - repoId: repoId ?? undefined, - stateScopeKey: scopeKey, - }); + const { + selectedState: selectedIssueState, + interaction, + assigneeConfig, + } = useGitHubIssueDetailState({ + repoPath, + repoId: repoId ?? undefined, + stateScopeKey: scopeKey, + }); const { mode, staged, focusPath, historySelection, allFiles, focusGitFile } = deriveSourceControlMainProps({ @@ -99,6 +102,7 @@ const SourceControlMainPane: React.FC = ({ timeline={selectedIssueState.timeline} timelineLoading={selectedIssueState.timelineLoading} interaction={interaction} + assigneeConfig={assigneeConfig} showHeader={false} /> diff --git a/src/modules/WorkStation/TabContent/renderers/githubIssueDetail.tsx b/src/modules/WorkStation/TabContent/renderers/githubIssueDetail.tsx index 50b5eb970..820157208 100644 --- a/src/modules/WorkStation/TabContent/renderers/githubIssueDetail.tsx +++ b/src/modules/WorkStation/TabContent/renderers/githubIssueDetail.tsx @@ -22,7 +22,8 @@ import type { UnifiedTabContentProps } from "../types"; const GitHubIssueDetailTabRenderer: React.FC = memo( ({ tab }) => { const tabData = tab.data as unknown as GitHubIssueDetailTabData; - const { selectedState, interaction } = useGitHubIssueDetailState(tabData); + const { selectedState, interaction, assigneeConfig } = + useGitHubIssueDetailState(tabData); const headerContent = useMemo( () => ( @@ -72,6 +73,7 @@ const GitHubIssueDetailTabRenderer: React.FC = memo( timeline={selectedState.timeline} timelineLoading={selectedState.timelineLoading} interaction={interaction} + assigneeConfig={assigneeConfig} showHeader={false} /> ); diff --git a/src/modules/shared/githubIssueAssignees.ts b/src/modules/shared/githubIssueAssignees.ts new file mode 100644 index 000000000..12985bca3 --- /dev/null +++ b/src/modules/shared/githubIssueAssignees.ts @@ -0,0 +1,34 @@ +import type { GitHubIssue, GitHubIssueUser } from "@src/api/tauri/github"; + +export function resolveGitHubAssigneeUsers( + currentAssignees: GitHubIssueUser[], + assignableUsers: GitHubIssueUser[], + assigneeLogins: string[] +): GitHubIssueUser[] { + const usersByLogin = new Map( + [...currentAssignees, ...assignableUsers].map((user) => [ + user.login.toLowerCase(), + user, + ]) + ); + + return assigneeLogins.map( + (login) => + usersByLogin.get(login.toLowerCase()) ?? { login, avatar_url: "" } + ); +} + +export function issueHasAssigneeLogins( + issue: GitHubIssue, + assigneeLogins: string[] +): boolean { + const actual = issue.assignees + .map((assignee) => assignee.login.toLowerCase()) + .sort(); + const expected = assigneeLogins.map((login) => login.toLowerCase()).sort(); + + return ( + actual.length === expected.length && + actual.every((login, index) => login === expected[index]) + ); +} diff --git a/src/modules/shared/hooks/useGitHubIssueDetailState.test.ts b/src/modules/shared/hooks/useGitHubIssueDetailState.test.ts index dcc0fa570..b78a7b8b6 100644 --- a/src/modules/shared/hooks/useGitHubIssueDetailState.test.ts +++ b/src/modules/shared/hooks/useGitHubIssueDetailState.test.ts @@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => ({ getGitHubViewerLogin: vi.fn(), listIssueTimelineLocal: vi.fn(), listIssuesLocal: vi.fn(), + listRepoAssigneesLocal: vi.fn(), updateIssueLocal: vi.fn(), })); @@ -61,7 +62,7 @@ const issue: GitHubIssue = { }; function Probe() { - const { interaction } = useGitHubIssueDetailState({ + const { interaction, assigneeConfig } = useGitHubIssueDetailState({ issueNumber: issue.number, repoPath: "/repos/ORG2", stateScopeKey: "issue-detail-test", @@ -74,6 +75,9 @@ function Probe() { "data-loading": String(interaction.loading), "data-viewer": interaction.viewer?.login, "data-duplicate-count": String(interaction.duplicateCandidates.length), + "data-assignee-disabled": String(assigneeConfig?.disabled), + "data-assignee-options": String(assigneeConfig?.options.length ?? 0), + "data-assignees": assigneeConfig?.currentAssigneeIds.join(","), }, createElement( "button", @@ -86,6 +90,25 @@ function Probe() { }, }, "Load duplicates" + ), + createElement( + "button", + { + type: "button", + "data-testid": "load-assignees", + onClick: () => void assigneeConfig?.onOpen?.(), + }, + "Load assignees" + ), + createElement( + "button", + { + type: "button", + "data-testid": "assign-collaborator", + onClick: () => + void assigneeConfig?.onChangeAssigneeIds(["collaborator"]), + }, + "Assign collaborator" ) ); } @@ -110,6 +133,7 @@ describe("useGitHubIssueDetailState", () => { can_manage_issues: true, can_manage_pull_requests: false, }); + mocks.listRepoAssigneesLocal.mockResolvedValue([]); store.set(workstationSelectedIssueAtomFamily("issue-detail-test"), { issue, timeline: [], @@ -229,4 +253,61 @@ describe("useGitHubIssueDetailState", () => { ).toBe("1"); }); }); + + it("loads and updates assignees when repository permissions allow it", async () => { + const collaborator = { + login: "collaborator", + avatar_url: "https://example.com/collaborator.png", + }; + mocks.listRepoAssigneesLocal.mockResolvedValue([collaborator]); + mocks.updateIssueLocal.mockResolvedValue({ + ...issue, + assignees: [collaborator], + }); + + await act(async () => { + root.render(createElement(Provider, { store }, createElement(Probe))); + }); + await vi.waitFor(() => { + expect( + container + .querySelector("[data-testid='issue-detail-state']") + ?.getAttribute("data-assignee-disabled") + ).toBe("false"); + }); + + await act(async () => { + container + .querySelector("[data-testid='load-assignees']") + ?.click(); + await Promise.resolve(); + }); + await vi.waitFor(() => { + expect(mocks.listRepoAssigneesLocal).toHaveBeenCalledWith("org2AI/ORG2"); + expect( + container + .querySelector("[data-testid='issue-detail-state']") + ?.getAttribute("data-assignee-options") + ).toBe("1"); + }); + + await act(async () => { + container + .querySelector("[data-testid='assign-collaborator']") + ?.click(); + await Promise.resolve(); + }); + await vi.waitFor(() => { + expect(mocks.updateIssueLocal).toHaveBeenCalledWith( + "org2AI/ORG2", + issue.number, + { assignees: ["collaborator"] } + ); + expect( + container + .querySelector("[data-testid='issue-detail-state']") + ?.getAttribute("data-assignees") + ).toBe("collaborator"); + }); + }); }); diff --git a/src/modules/shared/hooks/useGitHubIssueDetailState.ts b/src/modules/shared/hooks/useGitHubIssueDetailState.ts index 1a8e2c50c..14df74ffa 100644 --- a/src/modules/shared/hooks/useGitHubIssueDetailState.ts +++ b/src/modules/shared/hooks/useGitHubIssueDetailState.ts @@ -7,6 +7,7 @@ import { getGitHubViewerLogin, listIssueTimelineLocal, listIssuesLocal, + listRepoAssigneesLocal, updateIssueLocal, } from "@src/api/tauri/github"; import type { @@ -19,6 +20,11 @@ import type { GitHubIssueInteractionConfig, GitHubIssueStatusChangeOptions, } from "@src/modules/ProjectManager/WorkItems/components/WorkItemContent/types"; +import type { WorkItemExternalAssigneeConfig } from "@src/modules/ProjectManager/WorkItems/components/WorkItemProperties/types"; +import { + issueHasAssigneeLogins, + resolveGitHubAssigneeUsers, +} from "@src/modules/shared/githubIssueAssignees"; import { parseGithubRepoFullName } from "@src/services/git/operations/createPullRequest"; import { fetchIssue, @@ -48,9 +54,14 @@ interface GitHubIssueInteractionResolution { duplicateCandidatesLoaded: boolean; loadingDuplicateCandidates: boolean; duplicateCandidatesError: boolean; + assignableUsers: GitHubIssueUser[]; + assignableUsersLoaded: boolean; + loadingAssignableUsers: boolean; + assigneesError: string | null; submittingComment: boolean; updatingBody: boolean; updatingStatus: boolean; + updatingAssignees: boolean; error: GitHubIssueInteractionConfig["error"]; } @@ -144,6 +155,11 @@ export function useGitHubIssueDetailState({ generation: number; promise: Promise; } | null>(null); + const assigneeRequestRef = useRef<{ + key: string; + generation: number; + promise: Promise; + } | null>(null); useEffect(() => { selectedIssueRef.current = selectedState.issue; @@ -221,9 +237,14 @@ export function useGitHubIssueDetailState({ duplicateCandidatesLoaded: false, loadingDuplicateCandidates: false, duplicateCandidatesError: false, + assignableUsers: [], + assignableUsersLoaded: false, + loadingAssignableUsers: false, + assigneesError: null, submittingComment: false, updatingBody: false, updatingStatus: false, + updatingAssignees: false, error: null, }); }); @@ -236,6 +257,9 @@ export function useGitHubIssueDetailState({ if (duplicateRequestRef.current?.key === requestKey) { duplicateRequestRef.current = null; } + if (assigneeRequestRef.current?.key === requestKey) { + assigneeRequestRef.current = null; + } }; }, [repoFullName, requestKey]); @@ -313,6 +337,163 @@ export function useGitHubIssueDetailState({ return promise; }, [currentResolution, issueNumber, repoFullName, requestKey]); + const loadAssignableUsers = useCallback((): Promise => { + if ( + !requestKey || + !repoFullName || + !currentResolution || + currentResolution.permissions?.can_manage_issues !== true + ) { + return Promise.resolve(); + } + if (currentResolution.assignableUsersLoaded) { + return Promise.resolve(); + } + const generation = requestGenerationRef.current; + if ( + assigneeRequestRef.current?.key === requestKey && + assigneeRequestRef.current.generation === generation + ) { + return assigneeRequestRef.current.promise; + } + + setResolution((current) => + current?.key === requestKey + ? { + ...current, + loadingAssignableUsers: true, + assigneesError: null, + } + : current + ); + + const promise = listRepoAssigneesLocal(repoFullName) + .then((users) => { + setResolution((current) => + current?.key === requestKey && + requestGenerationRef.current === generation + ? { + ...current, + assignableUsers: users, + assignableUsersLoaded: true, + loadingAssignableUsers: false, + assigneesError: null, + } + : current + ); + }) + .catch((error: unknown) => { + setResolution((current) => + current?.key === requestKey && + requestGenerationRef.current === generation + ? { + ...current, + loadingAssignableUsers: false, + assigneesError: + error instanceof Error ? error.message : String(error), + } + : current + ); + }) + .finally(() => { + if (assigneeRequestRef.current?.promise === promise) { + assigneeRequestRef.current = null; + } + }); + + assigneeRequestRef.current = { key: requestKey, generation, promise }; + return promise; + }, [currentResolution, repoFullName, requestKey]); + + const changeAssignees = useCallback( + async (assigneeLogins: string[]): Promise => { + const issue = selectedState.issue; + if ( + !requestKey || + !repoFullName || + !issue || + !currentResolution || + currentResolution.permissions?.can_manage_issues !== true || + currentResolution.updatingAssignees + ) { + return; + } + + const previousAssignees = issue.assignees; + const optimisticAssignees = resolveGitHubAssigneeUsers( + previousAssignees, + currentResolution.assignableUsers, + assigneeLogins + ); + setResolution((current) => + current?.key === requestKey + ? { ...current, updatingAssignees: true, assigneesError: null } + : current + ); + setSelectedState((current) => + current.issue?.number === issue.number + ? { + ...current, + issue: { ...current.issue, assignees: optimisticAssignees }, + } + : current + ); + + try { + const updatedIssue = await updateIssueLocal( + repoFullName, + issue.number, + { assignees: assigneeLogins } + ); + if (!issueHasAssigneeLogins(updatedIssue, assigneeLogins)) { + throw new Error("GitHub did not apply the assignee update."); + } + setSelectedState((current) => + current.issue?.number === issue.number + ? { ...current, issue: updatedIssue } + : current + ); + setResolution((current) => + current?.key === requestKey + ? { + ...current, + updatingAssignees: false, + assigneesError: null, + } + : current + ); + callbacks.refreshIssues?.(); + } catch (error) { + setSelectedState((current) => + current.issue?.number === issue.number + ? { + ...current, + issue: { ...current.issue, assignees: previousAssignees }, + } + : current + ); + setResolution((current) => + current?.key === requestKey + ? { + ...current, + updatingAssignees: false, + assigneesError: + error instanceof Error ? error.message : String(error), + } + : current + ); + } + }, + [ + callbacks, + currentResolution, + repoFullName, + requestKey, + selectedState.issue, + setSelectedState, + ] + ); + const addComment = useCallback( async (body: string) => { const issue = selectedState.issue; @@ -550,5 +731,45 @@ export function useGitHubIssueDetailState({ updateBody, ]); - return { selectedState, interaction }; + const assigneeConfig = useMemo< + WorkItemExternalAssigneeConfig | undefined + >(() => { + const issue = selectedState.issue; + if (!issue || !requestKey || !currentResolution) return undefined; + + const usersByLogin = new Map(); + for (const user of [ + ...issue.assignees, + ...currentResolution.assignableUsers, + ]) { + usersByLogin.set(user.login.toLowerCase(), user); + } + const canManageAssignees = + currentResolution.permissions?.can_manage_issues === true; + + return { + currentAssigneeIds: issue.assignees.map((assignee) => assignee.login), + options: Array.from(usersByLogin.values()).map((user) => ({ + id: user.login, + label: user.login, + avatar: user.avatar_url, + })), + loading: currentResolution.loadingAssignableUsers, + error: currentResolution.assigneesError, + disabled: !canManageAssignees || currentResolution.updatingAssignees, + readonlyReason: canManageAssignees + ? undefined + : "Repository permission is required to manage issue assignees.", + onOpen: loadAssignableUsers, + onChangeAssigneeIds: changeAssignees, + }; + }, [ + changeAssignees, + currentResolution, + loadAssignableUsers, + requestKey, + selectedState.issue, + ]); + + return { selectedState, interaction, assigneeConfig }; } From 43a8143072a1c55e1ae2a05334f14cdae0ba2806 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Thu, 6 Aug 2026 20:53:02 +0800 Subject: [PATCH 2/2] fix(github): guard assignee updates across issue switches --- .../GitHubIssueAssigneeEditing.md | 39 ++++++++++++ .../hooks/useGitHubIssueDetailState.test.ts | 61 +++++++++++++++++++ .../shared/hooks/useGitHubIssueDetailState.ts | 35 +++++++++-- 3 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 docs/frontend-ui-audit-2026-08-06/GitHubIssueAssigneeEditing.md diff --git a/docs/frontend-ui-audit-2026-08-06/GitHubIssueAssigneeEditing.md b/docs/frontend-ui-audit-2026-08-06/GitHubIssueAssigneeEditing.md new file mode 100644 index 000000000..42081013d --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-06/GitHubIssueAssigneeEditing.md @@ -0,0 +1,39 @@ +# Frontend UI Audit — GitHub Issue Assignee Editing + +**Files:** `src/engines/ChatPanel/panels/GitHubIssuePanelView.tsx`, `src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/SourceControlMainContent/index.tsx`, `src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/SourceControlMainPane.tsx`, `src/modules/WorkStation/TabContent/renderers/githubIssueDetail.tsx` +**Date:** 2026-08-06 +**Auditor:** Codex PR audit + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| ---- | ----------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| — | Assignee control wiring | keep with reason | The changed hosts add no new raw interactive markup; each passes the same `assigneeConfig` into the existing shared issue-detail/property components. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| ---- | ----- | ---------------- | ---------------------------------------------------------------------------------- | ---------------- | +| — | — | keep with reason | No Tailwind class or inline visual token is added by the changed production hosts. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| ---- | ----- | ---------------- | --------------------------------------- | ---------------- | +| — | — | keep with reason | No size or color literal is introduced. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| ---- | ------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | +| — | Shared assignee selector | keep with reason | Permission and mutation state flow through the shared selector's native disabled state; all four hosts expose identical behavior without a parallel control. | — | + +## D5 — Visual Patterns Observed + +- The issue-detail hosts reuse one `WorkItemExternalAssigneeConfig` boundary; no duplicated visual implementation was added. + +## Summary + +- 0 fixes recommended +- 4 kept with documented reason +- 0 abstract candidates diff --git a/src/modules/shared/hooks/useGitHubIssueDetailState.test.ts b/src/modules/shared/hooks/useGitHubIssueDetailState.test.ts index b78a7b8b6..407f87542 100644 --- a/src/modules/shared/hooks/useGitHubIssueDetailState.test.ts +++ b/src/modules/shared/hooks/useGitHubIssueDetailState.test.ts @@ -310,4 +310,65 @@ describe("useGitHubIssueDetailState", () => { ).toBe("collaborator"); }); }); + + it("single-flights assignee updates and ignores completion after the issue scope changes", async () => { + const collaborator = { + login: "collaborator", + avatar_url: "https://example.com/collaborator.png", + }; + let resolveUpdate: ((value: GitHubIssue) => void) | undefined; + mocks.updateIssueLocal.mockImplementation( + () => + new Promise((resolve) => { + resolveUpdate = resolve; + }) + ); + + await act(async () => { + root.render(createElement(Provider, { store }, createElement(Probe))); + }); + await vi.waitFor(() => { + expect( + container + .querySelector("[data-testid='issue-detail-state']") + ?.getAttribute("data-assignee-disabled") + ).toBe("false"); + }); + + act(() => { + const assign = container.querySelector( + "[data-testid='assign-collaborator']" + ); + assign?.click(); + assign?.click(); + }); + expect(mocks.updateIssueLocal).toHaveBeenCalledOnce(); + + const otherIssue: GitHubIssue = { + ...issue, + id: 200_132, + title: "Different repository issue", + html_url: "https://github.com/acme/other/issues/132", + assignees: [], + }; + act(() => { + store.set(workstationSelectedIssueAtomFamily("issue-detail-test"), { + issue: otherIssue, + timeline: [], + loading: false, + timelineLoading: false, + error: null, + submittingComment: false, + }); + }); + + await act(async () => { + resolveUpdate?.({ ...issue, assignees: [collaborator] }); + await Promise.resolve(); + }); + + expect( + store.get(workstationSelectedIssueAtomFamily("issue-detail-test")).issue + ).toEqual(otherIssue); + }); }); diff --git a/src/modules/shared/hooks/useGitHubIssueDetailState.ts b/src/modules/shared/hooks/useGitHubIssueDetailState.ts index 14df74ffa..4fde3df77 100644 --- a/src/modules/shared/hooks/useGitHubIssueDetailState.ts +++ b/src/modules/shared/hooks/useGitHubIssueDetailState.ts @@ -160,6 +160,10 @@ export function useGitHubIssueDetailState({ generation: number; promise: Promise; } | null>(null); + const assigneeMutationRef = useRef<{ + key: string; + generation: number; + } | null>(null); useEffect(() => { selectedIssueRef.current = selectedState.issue; @@ -419,6 +423,15 @@ export function useGitHubIssueDetailState({ return; } + const generation = requestGenerationRef.current; + if ( + assigneeMutationRef.current?.key === requestKey && + assigneeMutationRef.current.generation === generation + ) { + return; + } + assigneeMutationRef.current = { key: requestKey, generation }; + const previousAssignees = issue.assignees; const optimisticAssignees = resolveGitHubAssigneeUsers( previousAssignees, @@ -431,7 +444,8 @@ export function useGitHubIssueDetailState({ : current ); setSelectedState((current) => - current.issue?.number === issue.number + requestGenerationRef.current === generation && + current.issue?.id === issue.id ? { ...current, issue: { ...current.issue, assignees: optimisticAssignees }, @@ -449,12 +463,14 @@ export function useGitHubIssueDetailState({ throw new Error("GitHub did not apply the assignee update."); } setSelectedState((current) => - current.issue?.number === issue.number + requestGenerationRef.current === generation && + current.issue?.id === issue.id ? { ...current, issue: updatedIssue } : current ); setResolution((current) => - current?.key === requestKey + current?.key === requestKey && + requestGenerationRef.current === generation ? { ...current, updatingAssignees: false, @@ -465,7 +481,8 @@ export function useGitHubIssueDetailState({ callbacks.refreshIssues?.(); } catch (error) { setSelectedState((current) => - current.issue?.number === issue.number + requestGenerationRef.current === generation && + current.issue?.id === issue.id ? { ...current, issue: { ...current.issue, assignees: previousAssignees }, @@ -473,7 +490,8 @@ export function useGitHubIssueDetailState({ : current ); setResolution((current) => - current?.key === requestKey + current?.key === requestKey && + requestGenerationRef.current === generation ? { ...current, updatingAssignees: false, @@ -482,6 +500,13 @@ export function useGitHubIssueDetailState({ } : current ); + } finally { + if ( + assigneeMutationRef.current?.key === requestKey && + assigneeMutationRef.current.generation === generation + ) { + assigneeMutationRef.current = null; + } } }, [