Skip to content

Commit deb50ed

Browse files
authored
Merge pull request Stack-Cairn#322 from Stack-Cairn/codex/fix-git-review-menu-position
fix(project-tools): prevent Git review action menu drift
2 parents 65caf80 + 683abd8 commit deb50ed

5 files changed

Lines changed: 142 additions & 94 deletions

File tree

crates/agent-gateway/web/src/components/project-tools/git-review/StatusView.tsx

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,11 @@ export function GitReviewStatusView(props: {
156156
8,
157157
);
158158
if (dx !== 0 || dy !== 0) {
159-
setChangesMenu({ ...changesMenu, x: changesMenu.x + dx, y: changesMenu.y + dy });
159+
setChangesMenu({
160+
...changesMenu,
161+
right: changesMenu.right - dx,
162+
y: changesMenu.y + dy,
163+
});
160164
}
161165
}, [changesMenu, panelRef]);
162166

@@ -293,10 +297,13 @@ export function GitReviewStatusView(props: {
293297
setChangeContextMenu(null);
294298
const panelRect = panelRef.current?.getBoundingClientRect();
295299
const buttonRect = event.currentTarget.getBoundingClientRect();
296-
// Anchor at the button's bottom-right corner; the menu right-aligns via
297-
// translateX(-100%) and the measured-clamp layout effect corrects it.
300+
// Anchor at the button's bottom-right corner. A stable outer positioning
301+
// layer keeps the inner menu's transform animation out of coordinate
302+
// measurement and boundary correction.
298303
setChangesMenu({
299-
x: panelRect ? buttonRect.right - panelRect.left : buttonRect.right,
304+
right: panelRect
305+
? panelRect.right - buttonRect.right
306+
: window.innerWidth - buttonRect.right,
300307
y: panelRect ? buttonRect.bottom - panelRect.top + 4 : buttonRect.bottom + 4,
301308
section,
302309
});
@@ -681,61 +688,66 @@ export function GitReviewStatusView(props: {
681688
{changesMenu ? (
682689
<div
683690
ref={changesMenuRef}
684-
role="menu"
685-
className={cn("absolute z-[75] min-w-56", CONTEXT_MENU_CONTAINER_CLASS)}
686-
style={{ left: changesMenu.x, top: changesMenu.y, transform: "translateX(-100%)" }}
687-
onClick={(event) => event.stopPropagation()}
688-
onContextMenu={(event) => {
689-
event.preventDefault();
690-
event.stopPropagation();
691-
}}
691+
className="absolute z-[75] min-w-56"
692+
style={{ right: changesMenu.right, top: changesMenu.y }}
692693
>
693-
{changesMenu.section === "changes" ? (
694+
<div
695+
role="menu"
696+
className={cn("w-full", CONTEXT_MENU_CONTAINER_CLASS)}
697+
style={{ transformOrigin: "top right" }}
698+
onClick={(event) => event.stopPropagation()}
699+
onContextMenu={(event) => {
700+
event.preventDefault();
701+
event.stopPropagation();
702+
}}
703+
>
704+
{changesMenu.section === "changes" ? (
705+
<button
706+
type="button"
707+
role="menuitem"
708+
className={CHANGE_CONTEXT_MENU_ITEM_CLASS}
709+
disabled={writeDisabled || busy !== "" || !hasStageableChanges}
710+
onClick={stageAllChanges}
711+
>
712+
<FilePenLine className="h-3.5 w-3.5" />
713+
<span>{t("projectTools.gitReview.stageAllChanges")}</span>
714+
</button>
715+
) : (
716+
<button
717+
type="button"
718+
role="menuitem"
719+
className={CHANGE_CONTEXT_MENU_ITEM_CLASS}
720+
disabled={writeDisabled || busy !== "" || !hasStagedChanges}
721+
onClick={unstageAllChanges}
722+
>
723+
<GitCommitHorizontal className="h-3.5 w-3.5" />
724+
<span>{t("projectTools.gitReview.unstageAllChanges")}</span>
725+
</button>
726+
)}
694727
<button
695728
type="button"
696729
role="menuitem"
697730
className={CHANGE_CONTEXT_MENU_ITEM_CLASS}
698-
disabled={writeDisabled || busy !== "" || !hasStageableChanges}
699-
onClick={stageAllChanges}
731+
disabled={writeDisabled || busy !== "" || !hasDiscardableChanges}
732+
onClick={discardAllChanges}
700733
>
701-
<FilePenLine className="h-3.5 w-3.5" />
702-
<span>{t("projectTools.gitReview.stageAllChanges")}</span>
734+
<Trash2 className="h-3.5 w-3.5" />
735+
<span>{t("projectTools.gitReview.discardAllChanges")}</span>
703736
</button>
704-
) : (
705737
<button
706738
type="button"
707739
role="menuitem"
708740
className={CHANGE_CONTEXT_MENU_ITEM_CLASS}
709-
disabled={writeDisabled || busy !== "" || !hasStagedChanges}
710-
onClick={unstageAllChanges}
741+
disabled={loading}
742+
onClick={() => {
743+
setChangesMenu(null);
744+
void refresh();
745+
}}
711746
>
712-
<GitCommitHorizontal className="h-3.5 w-3.5" />
713-
<span>{t("projectTools.gitReview.unstageAllChanges")}</span>
747+
<RefreshCw className="h-3.5 w-3.5" />
748+
<span>{t("projectTools.gitReview.refreshChanges")}</span>
714749
</button>
715-
)}
716-
<button
717-
type="button"
718-
role="menuitem"
719-
className={CHANGE_CONTEXT_MENU_ITEM_CLASS}
720-
disabled={writeDisabled || busy !== "" || !hasDiscardableChanges}
721-
onClick={discardAllChanges}
722-
>
723-
<Trash2 className="h-3.5 w-3.5" />
724-
<span>{t("projectTools.gitReview.discardAllChanges")}</span>
725-
</button>
726-
<button
727-
type="button"
728-
role="menuitem"
729-
className={CHANGE_CONTEXT_MENU_ITEM_CLASS}
730-
disabled={loading}
731-
onClick={() => {
732-
setChangesMenu(null);
733-
void refresh();
734-
}}
735-
>
736-
<RefreshCw className="h-3.5 w-3.5" />
737-
<span>{t("projectTools.gitReview.refreshChanges")}</span>
738-
</button>
750+
</div>
739751
</div>
740752
) : null}
741753
{changeContextMenu && contextEntry ? (

crates/agent-gateway/web/src/components/project-tools/git-review/model.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export type HistoryContextMenuState =
118118
};
119119

120120
export type ChangesMenuState = {
121-
x: number;
121+
right: number;
122122
y: number;
123123
section: ChangeListSection;
124124
};

crates/agent-gui/src/components/project-tools/git-review/StatusView.tsx

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,11 @@ export function GitReviewStatusView(props: {
156156
8,
157157
);
158158
if (dx !== 0 || dy !== 0) {
159-
setChangesMenu({ ...changesMenu, x: changesMenu.x + dx, y: changesMenu.y + dy });
159+
setChangesMenu({
160+
...changesMenu,
161+
right: changesMenu.right - dx,
162+
y: changesMenu.y + dy,
163+
});
160164
}
161165
}, [changesMenu, panelRef]);
162166

@@ -293,10 +297,13 @@ export function GitReviewStatusView(props: {
293297
setChangeContextMenu(null);
294298
const panelRect = panelRef.current?.getBoundingClientRect();
295299
const buttonRect = event.currentTarget.getBoundingClientRect();
296-
// Anchor at the button's bottom-right corner; the menu right-aligns via
297-
// translateX(-100%) and the measured-clamp layout effect corrects it.
300+
// Anchor at the button's bottom-right corner. A stable outer positioning
301+
// layer keeps the inner menu's transform animation out of coordinate
302+
// measurement and boundary correction.
298303
setChangesMenu({
299-
x: panelRect ? buttonRect.right - panelRect.left : buttonRect.right,
304+
right: panelRect
305+
? panelRect.right - buttonRect.right
306+
: window.innerWidth - buttonRect.right,
300307
y: panelRect ? buttonRect.bottom - panelRect.top + 4 : buttonRect.bottom + 4,
301308
section,
302309
});
@@ -681,61 +688,66 @@ export function GitReviewStatusView(props: {
681688
{changesMenu ? (
682689
<div
683690
ref={changesMenuRef}
684-
role="menu"
685-
className={cn("absolute z-[75] min-w-56", CONTEXT_MENU_CONTAINER_CLASS)}
686-
style={{ left: changesMenu.x, top: changesMenu.y, transform: "translateX(-100%)" }}
687-
onClick={(event) => event.stopPropagation()}
688-
onContextMenu={(event) => {
689-
event.preventDefault();
690-
event.stopPropagation();
691-
}}
691+
className="absolute z-[75] min-w-56"
692+
style={{ right: changesMenu.right, top: changesMenu.y }}
692693
>
693-
{changesMenu.section === "changes" ? (
694+
<div
695+
role="menu"
696+
className={cn("w-full", CONTEXT_MENU_CONTAINER_CLASS)}
697+
style={{ transformOrigin: "top right" }}
698+
onClick={(event) => event.stopPropagation()}
699+
onContextMenu={(event) => {
700+
event.preventDefault();
701+
event.stopPropagation();
702+
}}
703+
>
704+
{changesMenu.section === "changes" ? (
705+
<button
706+
type="button"
707+
role="menuitem"
708+
className={CHANGE_CONTEXT_MENU_ITEM_CLASS}
709+
disabled={writeDisabled || busy !== "" || !hasStageableChanges}
710+
onClick={stageAllChanges}
711+
>
712+
<FilePenLine className="h-3.5 w-3.5" />
713+
<span>{t("projectTools.gitReview.stageAllChanges")}</span>
714+
</button>
715+
) : (
716+
<button
717+
type="button"
718+
role="menuitem"
719+
className={CHANGE_CONTEXT_MENU_ITEM_CLASS}
720+
disabled={writeDisabled || busy !== "" || !hasStagedChanges}
721+
onClick={unstageAllChanges}
722+
>
723+
<GitCommitHorizontal className="h-3.5 w-3.5" />
724+
<span>{t("projectTools.gitReview.unstageAllChanges")}</span>
725+
</button>
726+
)}
694727
<button
695728
type="button"
696729
role="menuitem"
697730
className={CHANGE_CONTEXT_MENU_ITEM_CLASS}
698-
disabled={writeDisabled || busy !== "" || !hasStageableChanges}
699-
onClick={stageAllChanges}
731+
disabled={writeDisabled || busy !== "" || !hasDiscardableChanges}
732+
onClick={discardAllChanges}
700733
>
701-
<FilePenLine className="h-3.5 w-3.5" />
702-
<span>{t("projectTools.gitReview.stageAllChanges")}</span>
734+
<Trash2 className="h-3.5 w-3.5" />
735+
<span>{t("projectTools.gitReview.discardAllChanges")}</span>
703736
</button>
704-
) : (
705737
<button
706738
type="button"
707739
role="menuitem"
708740
className={CHANGE_CONTEXT_MENU_ITEM_CLASS}
709-
disabled={writeDisabled || busy !== "" || !hasStagedChanges}
710-
onClick={unstageAllChanges}
741+
disabled={loading}
742+
onClick={() => {
743+
setChangesMenu(null);
744+
void refresh();
745+
}}
711746
>
712-
<GitCommitHorizontal className="h-3.5 w-3.5" />
713-
<span>{t("projectTools.gitReview.unstageAllChanges")}</span>
747+
<RefreshCw className="h-3.5 w-3.5" />
748+
<span>{t("projectTools.gitReview.refreshChanges")}</span>
714749
</button>
715-
)}
716-
<button
717-
type="button"
718-
role="menuitem"
719-
className={CHANGE_CONTEXT_MENU_ITEM_CLASS}
720-
disabled={writeDisabled || busy !== "" || !hasDiscardableChanges}
721-
onClick={discardAllChanges}
722-
>
723-
<Trash2 className="h-3.5 w-3.5" />
724-
<span>{t("projectTools.gitReview.discardAllChanges")}</span>
725-
</button>
726-
<button
727-
type="button"
728-
role="menuitem"
729-
className={CHANGE_CONTEXT_MENU_ITEM_CLASS}
730-
disabled={loading}
731-
onClick={() => {
732-
setChangesMenu(null);
733-
void refresh();
734-
}}
735-
>
736-
<RefreshCw className="h-3.5 w-3.5" />
737-
<span>{t("projectTools.gitReview.refreshChanges")}</span>
738-
</button>
750+
</div>
739751
</div>
740752
) : null}
741753
{changeContextMenu && contextEntry ? (

crates/agent-gui/src/components/project-tools/git-review/model.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export type HistoryContextMenuState =
118118
};
119119

120120
export type ChangesMenuState = {
121-
x: number;
121+
right: number;
122122
y: number;
123123
section: ChangeListSection;
124124
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import assert from "node:assert/strict";
2+
import { readFileSync } from "node:fs";
3+
import test from "node:test";
4+
5+
const sourceRoots = [
6+
new URL("../../src/components/project-tools/git-review/", import.meta.url),
7+
new URL("../../../agent-gateway/web/src/components/project-tools/git-review/", import.meta.url),
8+
];
9+
10+
function source(root, relativePath) {
11+
return readFileSync(new URL(relativePath, root), "utf8");
12+
}
13+
14+
test("git review section menus isolate animation transforms from positioning", () => {
15+
for (const root of sourceRoots) {
16+
const model = source(root, "model.ts");
17+
const statusView = source(root, "StatusView.tsx");
18+
19+
assert.match(model, /type ChangesMenuState = \{\s+right: number;/);
20+
assert.match(statusView, /style=\{\{ right: changesMenu\.right, top: changesMenu\.y \}\}/);
21+
assert.match(statusView, /style=\{\{ transformOrigin: "top right" \}\}/);
22+
assert.doesNotMatch(statusView, /translateX\(-100%\)/);
23+
}
24+
});

0 commit comments

Comments
 (0)