fix(dashboard): unbreak banner dismiss + suppress when auto-sync handled it

Two bugs were keeping the Merge Advance Notice banner stuck on screen
even when there was nothing for the user to do:

  - Dismiss was dead: the `notice` memo never applied dismissedShas, so
    clicking close (or a successful Pull, which calls dismiss()) updated
    localStorage but the filter immediately re-matched the same event.
  - Auto-sync success was ignored: with mergeAdvanceAutoSync defaulting
    to "stash-and-ff", the merger snaps the project-root checkout
    forward as part of the merge — nothing left to pull — but the banner
    kept appearing. Clicking Pull then hit /api/git/pull which fetched
    origin (no change, the merger only advanced the local ref) and
    returned pull-clean with no real work done.

The notice memo now (a) filters dismissedShas, and (b) suppresses any
advance event whose autoSync entry for the current user's worktreePath
reports clean-sync or synced-with-edits-restored. Conflict + skipped
outcomes still surface so the user can recover.

Tests: dismiss removes the banner; clean-sync suppresses; pop-conflict
still surfaces; sibling-worktree success doesn't suppress this user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-23 15:57:48 -07:00
parent d8493f9467
commit 99359b6536
3 changed files with 108 additions and 5 deletions

View File

@@ -0,0 +1,14 @@
---
"@fusion/dashboard": patch
---
fix(dashboard): unbreak Merge Advance Notice banner dismiss and suppress when auto-sync already handled it
Two bugs were keeping the banner stuck on screen even when there was nothing for the user to do:
- **Dismiss was dead.** The `notice` memo never applied `dismissedShas`, so clicking the close button (or a successful Pull, which calls `dismiss()` after the API returns) updated localStorage but the same advance event kept matching the filter and the banner re-rendered immediately.
- **Auto-sync success was ignored.** With the new `mergeAdvanceAutoSync` setting at its `stash-and-ff` default, the merger snaps the project-root checkout forward as part of the merge — there is nothing left to pull. The banner kept appearing anyway because the route's `autoSync` payload wasn't consulted. Clicking Pull then hit `/api/git/pull`, which fetched origin (no change, since the merger only advanced the local ref) and returned `pull-clean` with no actual work done.
The `notice` memo now (a) filters out `dismissedShas`, and (b) suppresses any advance event whose `autoSync` entry for the *current user's* `worktreePath` reports `clean-sync` or `synced-with-edits-restored`. Conflict and skipped outcomes (`synced-with-pop-conflict`, `skipped-dirty`, `skipped-*`, `failed`) still surface the banner so the user can recover.
Banner suppression checks the per-worktree path, so a multi-checkout project where auto-sync handled one root and a sibling root is still stale will keep showing the banner on the stale one.

View File

@@ -139,6 +139,77 @@ describe("useMergeAdvanceNotice", () => {
expect(result.current.conflictState).toBeNull(); expect(result.current.conflictState).toBeNull();
}); });
it("dismiss() actually removes the banner — dismissedShas filter is applied in the notice memo", async () => {
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-dismiss" }));
await waitFor(() => expect(result.current.notice).not.toBeNull());
expect(result.current.notice?.toSha).toBe("abcdef123456");
act(() => result.current.dismiss());
await waitFor(() => expect(result.current.notice).toBeUndefined());
});
it("auto-sync success (clean-sync) for this user's worktree suppresses the banner", async () => {
const handledPayload = {
events: [{
...eventPayload.events[0],
toSha: "auto-handled-1",
autoSync: [{ worktreePath: "/repo", outcome: "clean-sync", mode: "stash-and-ff" }],
}],
};
mocked.api.mockImplementation(async (path: string) => {
if (String(path).includes("merge-advance-events")) return handledPayload;
if (String(path).includes("push-status")) return pushStatus;
return { ok: true };
});
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-auto-handled" }));
await waitFor(() => expect(result.current.pushStatus?.localSha).toBe("localsha"));
// Banner must NOT appear — auto-sync already brought the worktree forward.
expect(result.current.notice).toBeUndefined();
});
it("auto-sync conflict (synced-with-pop-conflict) still surfaces the banner so the user can recover", async () => {
const conflictPayload = {
events: [{
...eventPayload.events[0],
toSha: "auto-conflict-1",
autoSync: [{
worktreePath: "/repo",
outcome: "synced-with-pop-conflict",
mode: "stash-and-ff",
patchPath: "/tmp/fusion-worktree-sync-abc/edits.patch",
conflictedFiles: ["src/a.ts"],
}],
}],
};
mocked.api.mockImplementation(async (path: string) => {
if (String(path).includes("merge-advance-events")) return conflictPayload;
if (String(path).includes("push-status")) return pushStatus;
return { ok: true };
});
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-auto-conflict" }));
await waitFor(() => expect(result.current.notice).not.toBeNull());
expect(result.current.notice?.toSha).toBe("auto-conflict-1");
});
it("auto-sync success on a DIFFERENT worktree path does not suppress the banner for this user", async () => {
// Two project-root checkouts on the same branch: auto-sync handled the
// other one (/other-repo) but the current user is on /repo and still has
// a stale checkout.
const mixedPayload = {
events: [{
...eventPayload.events[0],
toSha: "mixed-1",
autoSync: [{ worktreePath: "/other-repo", outcome: "clean-sync", mode: "stash-and-ff" }],
}],
};
mocked.api.mockImplementation(async (path: string) => {
if (String(path).includes("merge-advance-events")) return mixedPayload;
if (String(path).includes("push-status")) return pushStatus;
return { ok: true };
});
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-mixed" }));
await waitFor(() => expect(result.current.notice).not.toBeNull());
});
it("pull stash-conflict opens conflict state and preserves error visibility", async () => { it("pull stash-conflict opens conflict state and preserves error visibility", async () => {
const conflictEventPayload = { events: [{ ...eventPayload.events[0], toSha: "conflict12345" }] }; const conflictEventPayload = { events: [{ ...eventPayload.events[0], toSha: "conflict12345" }] };
let callIndex = 0; let callIndex = 0;

View File

@@ -179,11 +179,29 @@ export function useMergeAdvanceNotice({ projectId, apiBase = "/api" }: { project
return () => unsubscribe(); return () => unsubscribe();
}, [apiBase, fetchEvents, fetchPushStatus, projectId]); }, [apiBase, fetchEvents, fetchPushStatus, projectId]);
const notice = useMemo(() => events.find((event) => ( const notice = useMemo(() => {
event.succeeded === true const dismissed = new Set(dismissedShas);
&& event.userCheckout !== null return events.find((event) => {
&& event.userCheckout.worktreePath.trim().length > 0 if (event.succeeded !== true) return false;
)), [events]); if (event.userCheckout === null) return false;
if (event.userCheckout.worktreePath.trim().length === 0) return false;
if (dismissed.has(event.toSha)) return false;
// Suppress the banner when the merger's auto-sync hook already brought
// this user's checkout forward — `clean-sync` and
// `synced-with-edits-restored` outcomes mean the worktree is already at
// the new tip and there is nothing for the user to pull. Outcomes like
// `synced-with-pop-conflict`, `skipped-dirty`, `skipped-*`, and
// `failed` (or no auto-sync at all when the setting is `off`) leave
// the worktree behind, so the banner must still surface.
const userWorktreePath = event.userCheckout.worktreePath;
const successOutcomes = new Set(["clean-sync", "synced-with-edits-restored"]);
const handledByAutoSync = (event.autoSync ?? []).some((entry) =>
entry.worktreePath === userWorktreePath && successOutcomes.has(entry.outcome),
);
if (handledByAutoSync) return false;
return true;
});
}, [dismissedShas, events]);
const dismiss = useCallback(() => { const dismiss = useCallback(() => {
if (!notice) return; if (!notice) return;