Skip to content

Commit 960b86d

Browse files
authored
Fix rebase workflow dismissal handling (#547)
1 parent 9e7a566 commit 960b86d

15 files changed

Lines changed: 329 additions & 95 deletions

File tree

apps/ade-cli/src/services/sync/syncRemoteCommandService.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2058,7 +2058,6 @@ function registerLaneRemoteCommands({ args, register }: RemoteCommandRegistratio
20582058
register("lanes.listRebaseSuggestions", { viewerAllowed: true }, async () => args.rebaseSuggestionService?.listSuggestions() ?? []);
20592059
register("lanes.dismissRebaseSuggestion", { viewerAllowed: true, queueable: true }, async (payload) => {
20602060
const laneId = requireString(payload.laneId, "lanes.dismissRebaseSuggestion requires laneId.");
2061-
args.conflictService?.dismissRebase(laneId);
20622061
if (args.rebaseSuggestionService) {
20632062
await args.rebaseSuggestionService.dismiss({ laneId });
20642063
}
@@ -2067,8 +2066,6 @@ function registerLaneRemoteCommands({ args, register }: RemoteCommandRegistratio
20672066
register("lanes.deferRebaseSuggestion", { viewerAllowed: true, queueable: true }, async (payload) => {
20682067
const laneId = requireString(payload.laneId, "lanes.deferRebaseSuggestion requires laneId.");
20692068
const minutes = Math.max(5, Math.min(7 * 24 * 60, Math.floor(asOptionalNumber(payload.minutes) ?? 60)));
2070-
const until = new Date(Date.now() + minutes * 60_000).toISOString();
2071-
args.conflictService?.deferRebase(laneId, until);
20722069
if (args.rebaseSuggestionService) {
20732070
await args.rebaseSuggestionService.defer({
20742071
laneId,

apps/desktop/src/main/services/adeActions/registry.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,14 +1450,11 @@ function buildLaneDomainService(runtime: AdeRuntime): OpaqueService {
14501450
},
14511451
dismissRebaseSuggestion: async (args?: { laneId?: string }) => {
14521452
const laneId = requireNonEmptyString(args?.laneId, "laneId");
1453-
runtime.conflictService?.dismissRebase(laneId);
14541453
await runtime.rebaseSuggestionService?.dismiss({ laneId });
14551454
},
14561455
deferRebaseSuggestion: async (args?: { laneId?: string; minutes?: number }) => {
14571456
const laneId = requireNonEmptyString(args?.laneId, "laneId");
14581457
const minutes = Math.max(5, Math.min(7 * 24 * 60, Math.floor(args?.minutes ?? 60)));
1459-
const until = new Date(Date.now() + minutes * 60_000).toISOString();
1460-
runtime.conflictService?.deferRebase(laneId, until);
14611458
await runtime.rebaseSuggestionService?.defer({ laneId, minutes });
14621459
},
14631460
listAutoRebaseStatuses: () =>

apps/desktop/src/main/services/lanes/laneService.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4066,8 +4066,13 @@ export function createLaneService({
40664066
let parentHead = "";
40674067
let parentTargetLabel = "";
40684068
let operationMetadata: Record<string, unknown> = {
4069+
runId,
4070+
rootLaneId: target.id,
40694071
reason,
40704072
recursive: scope === "lane_and_descendants",
4073+
scope,
4074+
pushMode,
4075+
actor,
40714076
};
40724077
try {
40734078
const parent = lane.parent_lane_id ? getLaneRow(lane.parent_lane_id) : null;

apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,14 @@ function createMockConflictService() {
487487
} as any;
488488
}
489489

490+
function createMockRebaseSuggestionService() {
491+
return {
492+
listSuggestions: vi.fn().mockResolvedValue([]),
493+
dismiss: vi.fn().mockResolvedValue(undefined),
494+
defer: vi.fn().mockResolvedValue(undefined),
495+
} as any;
496+
}
497+
490498
function createMockWorkerAgentService() {
491499
return {
492500
listAgents: vi.fn().mockReturnValue([]),
@@ -610,6 +618,7 @@ describe("createSyncRemoteCommandService", () => {
610618
let linearSyncService: ReturnType<typeof createMockLinearSyncService>;
611619
let linearCredentialService: ReturnType<typeof createMockLinearCredentialService>;
612620
let conflictService: ReturnType<typeof createMockConflictService>;
621+
let rebaseSuggestionService: ReturnType<typeof createMockRebaseSuggestionService>;
613622
let processService: ReturnType<typeof createMockProcessService>;
614623
let issueInventoryService: ReturnType<typeof createMockIssueInventoryService>;
615624
let queueLandingService: ReturnType<typeof createMockQueueLandingService>;
@@ -631,6 +640,7 @@ describe("createSyncRemoteCommandService", () => {
631640
linearSyncService = createMockLinearSyncService();
632641
linearCredentialService = createMockLinearCredentialService();
633642
conflictService = createMockConflictService();
643+
rebaseSuggestionService = createMockRebaseSuggestionService();
634644
processService = createMockProcessService();
635645
issueInventoryService = createMockIssueInventoryService();
636646
queueLandingService = createMockQueueLandingService();
@@ -652,6 +662,7 @@ describe("createSyncRemoteCommandService", () => {
652662
getLinearIssueTracker: () => linearIssueTracker,
653663
getLinearSyncService: () => linearSyncService,
654664
conflictService,
665+
rebaseSuggestionService,
655666
processService,
656667
logger: createLogger() as any,
657668
});
@@ -3172,36 +3183,29 @@ describe("createSyncRemoteCommandService", () => {
31723183
}))).rejects.toThrow("lanes.rebasePush requires laneIds.");
31733184
});
31743185

3175-
it("lanes.dismissRebaseSuggestion routes to conflictService even without a rebaseSuggestionService", async () => {
3186+
it("lanes.dismissRebaseSuggestion hides the banner without dismissing the rebase need", async () => {
31763187
const result = await service.execute(makePayload("lanes.dismissRebaseSuggestion", {
31773188
laneId: "lane-1",
31783189
}));
3179-
expect(conflictService.dismissRebase).toHaveBeenCalledWith("lane-1");
3190+
expect(rebaseSuggestionService.dismiss).toHaveBeenCalledWith({ laneId: "lane-1" });
3191+
expect(conflictService.dismissRebase).not.toHaveBeenCalled();
31803192
expect(result).toEqual({ ok: true });
31813193
});
31823194

3183-
it("lanes.deferRebaseSuggestion clamps minutes and forwards an ISO timestamp to conflictService", async () => {
3184-
vi.useFakeTimers();
3185-
try {
3186-
vi.setSystemTime(new Date("2026-04-15T22:00:00.000Z"));
3187-
await service.execute(makePayload("lanes.deferRebaseSuggestion", {
3188-
laneId: "lane-1",
3189-
minutes: 1,
3190-
}));
3191-
expect(conflictService.deferRebase).toHaveBeenCalledWith(
3192-
"lane-1",
3193-
"2026-04-15T22:05:00.000Z",
3194-
);
3195-
conflictService.deferRebase.mockClear();
3196-
await service.execute(makePayload("lanes.deferRebaseSuggestion", {
3197-
laneId: "lane-1",
3198-
minutes: 60 * 24 * 30,
3199-
}));
3200-
const [, until] = conflictService.deferRebase.mock.calls.at(-1) ?? [];
3201-
expect(until).toBe(new Date(Date.parse("2026-04-15T22:00:00.000Z") + 7 * 24 * 60 * 60_000).toISOString());
3202-
} finally {
3203-
vi.useRealTimers();
3204-
}
3195+
it("lanes.deferRebaseSuggestion clamps minutes and snoozes the banner without deferring the rebase need", async () => {
3196+
await service.execute(makePayload("lanes.deferRebaseSuggestion", {
3197+
laneId: "lane-1",
3198+
minutes: 1,
3199+
}));
3200+
expect(rebaseSuggestionService.defer).toHaveBeenCalledWith({ laneId: "lane-1", minutes: 5 });
3201+
expect(conflictService.deferRebase).not.toHaveBeenCalled();
3202+
3203+
await service.execute(makePayload("lanes.deferRebaseSuggestion", {
3204+
laneId: "lane-1",
3205+
minutes: 60 * 24 * 30,
3206+
}));
3207+
expect(rebaseSuggestionService.defer).toHaveBeenLastCalledWith({ laneId: "lane-1", minutes: 7 * 24 * 60 });
3208+
expect(conflictService.deferRebase).not.toHaveBeenCalled();
32053209
});
32063210
});
32073211

apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type * as ReactNamespace from "react";
77
import type * as RouterNamespace from "react-router-dom";
88
import { ADE_OPEN_BUILT_IN_BROWSER_EVENT } from "../../lib/openExternal";
99

10+
const ROUTE_INTEGRATION_TIMEOUT_MS = 45_000;
11+
1012
const workLifecycle = vi.hoisted(() => ({
1113
mounts: 0,
1214
unmounts: 0,
@@ -186,6 +188,7 @@ vi.mock("../lanes/LanesPage", async () => {
186188

187189
describe("App Work route keep-alive", () => {
188190
beforeEach(() => {
191+
cleanup();
189192
vi.clearAllMocks();
190193
workLifecycle.mounts = 0;
191194
workLifecycle.unmounts = 0;
@@ -257,7 +260,7 @@ describe("App Work route keep-alive", () => {
257260
});
258261
expect(workLifecycle.mounts).toBe(1);
259262
expect(workLifecycle.unmounts).toBe(0);
260-
});
263+
}, ROUTE_INTEGRATION_TIMEOUT_MS);
261264

262265
it("parks the native Work browser view when the Work route is backgrounded", async () => {
263266
const { App } = await import("./App");
@@ -285,7 +288,7 @@ describe("App Work route keep-alive", () => {
285288
visible: false,
286289
});
287290
});
288-
});
291+
}, ROUTE_INTEGRATION_TIMEOUT_MS);
289292

290293
it("reveals the Work browser pane when an ADE browser URL opens from another tab", async () => {
291294
window.history.replaceState({}, "", "/files");
@@ -310,7 +313,7 @@ describe("App Work route keep-alive", () => {
310313
workSidebarTab: "browser",
311314
}),
312315
);
313-
});
316+
}, ROUTE_INTEGRATION_TIMEOUT_MS);
314317

315318
it("hydrates project stores with launch clipboard reminder preferences", async () => {
316319
appStoreState.launchPromptClipboardNoticeEnabled = false;

apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5797,7 +5797,7 @@ describe("AgentChatPane submit recovery", () => {
57975797
await waitFor(() => {
57985798
expect(create).toHaveBeenCalledTimes(2);
57995799
expect(send).toHaveBeenCalledTimes(2);
5800-
});
5800+
}, { timeout: 5000 });
58015801
expect(writeClipboardText).toHaveBeenCalledTimes(1);
58025802
expect(writeClipboardText).toHaveBeenCalledWith("Fix the login bug");
58035803
expect(create).toHaveBeenNthCalledWith(1, expect.objectContaining({

apps/desktop/src/renderer/components/prs/PrRebaseBanner.test.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ describe("PrRebaseBanner", () => {
3535
execute: vi.fn(async () => {
3636
throw new Error("rebase failed");
3737
}),
38-
dismiss: vi.fn(),
38+
},
39+
lanes: {
40+
dismissRebaseSuggestion: vi.fn(),
3941
},
4042
};
4143

@@ -58,7 +60,9 @@ describe("PrRebaseBanner", () => {
5860
(window as any).ade = {
5961
rebase: {
6062
execute: vi.fn(async () => undefined),
61-
dismiss: vi.fn(),
63+
},
64+
lanes: {
65+
dismissRebaseSuggestion: vi.fn(),
6266
},
6367
};
6468

@@ -77,4 +81,34 @@ describe("PrRebaseBanner", () => {
7781
await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1));
7882
expect(onRebaseDone).toHaveBeenCalledTimes(1);
7983
});
84+
85+
it("hides the banner without dismissing the active rebase need", async () => {
86+
const dismissRebaseSuggestion = vi.fn(async () => undefined);
87+
const onRefresh = vi.fn(async () => undefined);
88+
(window as any).ade = {
89+
rebase: {
90+
execute: vi.fn(async () => undefined),
91+
dismiss: vi.fn(),
92+
},
93+
lanes: {
94+
dismissRebaseSuggestion,
95+
},
96+
};
97+
98+
render(
99+
<PrRebaseBanner
100+
laneId="lane-1"
101+
rebaseNeeds={[makeNeed()]}
102+
onTabChange={() => {}}
103+
onRefresh={onRefresh}
104+
/>,
105+
);
106+
107+
fireEvent.click(screen.getByRole("button", { name: /HIDE BANNER/i }));
108+
109+
await waitFor(() => expect(dismissRebaseSuggestion).toHaveBeenCalledWith({ laneId: "lane-1" }));
110+
expect((window as any).ade.rebase.dismiss).not.toHaveBeenCalled();
111+
expect(onRefresh).toHaveBeenCalledTimes(1);
112+
expect(screen.queryByText(/2 commits behind main/i)).toBeNull();
113+
});
80114
});

apps/desktop/src/renderer/components/prs/PrRebaseBanner.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ export function PrRebaseBanner({ laneId, rebaseNeeds, autoRebaseStatuses, onTabC
1919

2020
const need = React.useMemo(() => {
2121
const laneNeed = findLaneBaseNeed(rebaseNeeds, laneId);
22-
if (!laneNeed || laneNeed.behindBy <= 0 || laneNeed.dismissedAt) return null;
23-
if (laneNeed.deferredUntil && new Date(laneNeed.deferredUntil) > new Date()) return null;
22+
if (!laneNeed || laneNeed.behindBy <= 0) return null;
2423
return laneNeed;
2524
}, [laneId, rebaseNeeds]);
2625
const autoStatus = autoRebaseStatuses?.find((s) => s.laneId === laneId);
@@ -93,7 +92,7 @@ export function PrRebaseBanner({ laneId, rebaseNeeds, autoRebaseStatuses, onTabC
9392
const handleDismiss = async () => {
9493
setActionError(null);
9594
try {
96-
await window.ade.rebase.dismiss(laneId);
95+
await window.ade.lanes.dismissRebaseSuggestion({ laneId });
9796
await onRefresh?.();
9897
setDismissed(true);
9998
} catch (error) {
@@ -168,7 +167,7 @@ export function PrRebaseBanner({ laneId, rebaseNeeds, autoRebaseStatuses, onTabC
168167
}}
169168
onClick={() => void handleDismiss()}
170169
>
171-
DISMISS
170+
HIDE BANNER
172171
</button>
173172
</div>
174173
</div>

apps/desktop/src/renderer/components/prs/tabs/IntegrationTab.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { isDirtyWorktreeErrorMessage, stripDirtyWorktreePrefix } from "../shared
2424
import { deriveIntegrationPrLiveModel } from "../shared/integrationPrModel";
2525
import { PrAiResolverPanel } from "../shared/PrAiResolverPanel";
2626
import { findLaneBaseNeed, findMatchingRebaseNeed, rebaseNeedItemKey } from "../shared/rebaseNeedUtils";
27+
import { getActiveRebaseNeeds } from "./rebaseWorkflowModel";
2728

2829
/* ---- Outcome dot with design-system colors ---- */
2930

@@ -603,8 +604,8 @@ export function IntegrationTab({ prs, lanes, mergeContextByPrId, mergeMethod, se
603604
);
604605
const rebaseNeedByLaneId = React.useMemo(
605606
() => new Map(
606-
rebaseNeeds
607-
.filter((need) => need.kind === "lane_base" && need.behindBy > 0 && !need.dismissedAt)
607+
getActiveRebaseNeeds(rebaseNeeds)
608+
.filter((need) => need.kind === "lane_base")
608609
.map((need) => [need.laneId, need] as const),
609610
),
610611
[rebaseNeeds],

apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@ export function RebaseTab({
500500
if (!selectedNeed) return;
501501
setRebaseError(null);
502502
try {
503-
await window.ade.rebase.dismiss(selectedNeed.laneId);
503+
await window.ade.lanes.dismissRebaseSuggestion({ laneId: selectedNeed.laneId });
504504
await onRefresh();
505505
} catch (err: unknown) {
506506
setRebaseError(err instanceof Error ? err.message : String(err));
@@ -509,10 +509,9 @@ export function RebaseTab({
509509

510510
const handleDefer = async () => {
511511
if (!selectedNeed) return;
512-
const until = new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString();
513512
setRebaseError(null);
514513
try {
515-
await window.ade.rebase.defer(selectedNeed.laneId, until);
514+
await window.ade.lanes.deferRebaseSuggestion({ laneId: selectedNeed.laneId, minutes: 4 * 60 });
516515
await onRefresh();
517516
} catch (err: unknown) {
518517
setRebaseError(err instanceof Error ? err.message : String(err));
@@ -1329,7 +1328,7 @@ export function RebaseTab({
13291328
>
13301329
<Clock size={12} className="mr-1" />
13311330
<span className="font-mono font-bold uppercase" style={{ fontSize: 10, letterSpacing: "1px" }}>
1332-
DEFER 4H
1331+
SNOOZE BANNER 4H
13331332
</span>
13341333
</Button>
13351334
<Button
@@ -1341,7 +1340,7 @@ export function RebaseTab({
13411340
>
13421341
<XCircle size={12} className="mr-1" />
13431342
<span className="font-mono font-bold uppercase" style={{ fontSize: 10, letterSpacing: "1px" }}>
1344-
DISMISS
1343+
HIDE BANNER
13451344
</span>
13461345
</Button>
13471346
</div>

0 commit comments

Comments
 (0)