Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions apps/desktop/src/main/services/prs/prAsync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,79 @@ describe("prMergeAutoSettlementService", () => {
);
});

it("settles a PR that was already merged when first seen, but announces nothing", async () => {
// The machine-switch bug. Point the project tab at another machine and that
// machine reconciles, backfilling rows for PRs it had never stored. Every
// one arrives already merged, with a freshly-minted `randomUUID()` id that
// no `handledPrIds` list can match and a `mergedAt` comfortably after that
// machine's `enabledSince` — so each looked like breaking news, and the user
// got a stack of toasts about PRs that landed days ago.
//
// Filing the sessions is still right; announcing is not.
const db = createMemoryDb();
const settleSessionsWithOutcome = vi.fn((ids: string[]) => ids);
const emitEvent = vi.fn();
const service = createPrMergeAutoSettlementService({
db: db as any,
sessionService: {
list: vi.fn(() => [{
id: "chat-ready",
toolType: "claude-chat",
archivedAt: null,
settledAt: null,
}]),
settleSessionsWithOutcome,
} as any,
agentChatService: { getSettlementBlockers: vi.fn(async () => []) } as any,
emitEvent,
});

// A first snapshot establishes the baseline state, as any running app has.
await service.processSnapshot({
prs: [createSummary({ id: "pr-existing", githubPrNumber: 1, state: "open" })],
polledAt: "2026-03-24T12:00:00.000Z",
});

// Now the tab switches machines and a batch of long-merged PRs appears for
// the first time — merged AFTER enabledSince, so the old guard let them all
// through.
const historic = [
createSummary({
id: "pr-977", githubPrNumber: 977, state: "merged", mergedAt: "2026-03-24T12:00:30.000Z",
}),
createSummary({
id: "pr-983", githubPrNumber: 983, state: "merged", mergedAt: "2026-03-24T12:00:40.000Z",
}),
];
await service.processSnapshot({
prs: historic,
polledAt: "2026-03-24T12:05:00.000Z",
});

expect(settleSessionsWithOutcome).toHaveBeenCalledTimes(2);
expect(emitEvent).not.toHaveBeenCalled();

// And a merge we actually watch still announces itself, so the fix does not
// simply mute the feature.
const watched = createSummary({
id: "pr-991", githubPrNumber: 991, state: "open",
});
await service.processSnapshot({
prs: [...historic, watched],
polledAt: "2026-03-24T12:06:00.000Z",
});
await service.processSnapshot({
prs: [...historic, { ...watched, state: "merged", mergedAt: "2026-03-24T12:07:00.000Z" }],
polledAt: "2026-03-24T12:07:05.000Z",
});

expect(emitEvent).toHaveBeenCalledTimes(1);
expect(emitEvent).toHaveBeenCalledWith(expect.objectContaining({
type: "pr-sessions-auto-settled",
prNumber: 991,
}));
});

it("honors a concurrent disable while blocker checks are in flight", async () => {
const db = createMemoryDb();
let releaseBlockerCheck: (() => void) | null = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,48 @@ export function createPrMergeAutoSettlementService(args: {
agentChatService: Pick<ReturnType<typeof createAgentChatService>, "getSettlementBlockers">;
emitEvent: (event: PrEventPayload) => void;
}) {
/**
* The currently open or draft PRs in the previous snapshot, so a merge we
* WATCHED can be told apart from one that was already history when it arrived.
*
* `handledPrIds` cannot answer this. It is keyed by `pr.id`, which the GitHub
* backfill mints with `randomUUID()` per machine — so the same PR carries
* different ids in different machines' databases and the list never matches
* across them. Nor can `enabledSince`: it asks "did this merge after we turned
* the feature on", which is true of every PR merged in the last several weeks.
*
* Together those made switching the project tab to another machine announce
* its entire merge history: that machine reconciles, backfills rows for PRs it
* had never stored, and every one of them looks brand new and freshly merged.
*
* Deliberately in memory. Restarting ADE means we did not watch anything, so
* treating the first snapshot as history is the correct answer, not a lost
* one — the same conclusion `lifecycleNotificationKind` reaches when it
* returns null for a first-sight merged PR.
*/
// The polling snapshot includes every PR stored for the project, including
// terminal history. Only currently open or draft PRs can later produce a
// merge transition, so retain that bounded watch set instead of every state
// we have ever observed.
const previouslyWatchablePrIds = new Set<string>();

const processSnapshot = async ({
prs,
polledAt,
}: {
prs: PrSummary[];
polledAt: string;
}): Promise<void> => {
// Captured before anything can mutate it, and updated only at the end of a
// successful pass, so a snapshot that returns early does not silently
// consume its own evidence.
const previouslyWatchedPrIds = new Set(previouslyWatchablePrIds);
const rememberSnapshot = () => {
previouslyWatchablePrIds.clear();
for (const pr of prs) {
if (pr.state === "draft" || pr.state === "open") previouslyWatchablePrIds.add(pr.id);
}
};
const settings = getSessionLifecycleSettings(args.db);
const state = getPrMergeAutoSettlementState(args.db);
if (!state) {
Expand All @@ -41,9 +76,13 @@ export function createPrMergeAutoSettlementService(args: {
now: polledAt,
enabled: settings.autoSettleLaneSessionsOnPrMerge,
});
rememberSnapshot();
return;
}
if (!settings.autoSettleLaneSessionsOnPrMerge || !state.enabledSince) {
rememberSnapshot();
return;
}
if (!settings.autoSettleLaneSessionsOnPrMerge || !state.enabledSince) return;

const enabledSince = state.enabledSince;
const candidates = prs.filter(
Expand Down Expand Up @@ -103,7 +142,15 @@ export function createPrMergeAutoSettlementService(args: {
});
}

if (settledSessionIds.length > 0) {
// Settling still happens for a PR we are meeting for the first time —
// its sessions really are finished and should be filed. Announcing it
// does not: "PR #977 merged" is news only if you did not already know,
// and a PR that was merged before we ever laid eyes on it is history.
//
// Gated here rather than in the toast so every consumer inherits it —
// desktop toasts, mobile push, and anything added later.
const watchedItMerge = previouslyWatchedPrIds.has(pr.id);
if (settledSessionIds.length > 0 && watchedItMerge) {
args.emitEvent({
type: "pr-sessions-auto-settled",
timestamp: polledAt,
Expand All @@ -116,6 +163,7 @@ export function createPrMergeAutoSettlementService(args: {
});
}
}
rememberSnapshot();
};

return {
Expand Down
124 changes: 117 additions & 7 deletions apps/desktop/src/renderer/components/app/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
SESSION_TONE_TEXT_CLASS,
} from "../../../shared/sessionStatusPresentation";
import { useAppStore } from "../../state/appStore";
import { THIS_MACHINE_ID } from "../../../shared/machineIdentity";

/** Surfaces the router location so navigation assertions read the real URL. */
function LocationProbe() {
Expand Down Expand Up @@ -1030,13 +1031,122 @@ describe("CommandPalette", () => {
expect(
document.querySelectorAll('[data-thread-id="session-1"]'),
).toHaveLength(1);
// The deduped row keeps the LOCAL identity — no machine marker, no
// binding — so picking it takes the synchronous path.
expect(
document
.querySelector<HTMLElement>('[data-thread-id="session-1"]')
?.dataset.machineId,
).toBeUndefined();
// The deduped row keeps the LOCAL identity — so picking it takes the
// synchronous path. It is attributed to this Mac rather than to nothing
// (that is what makes it findable by machine name), and attribution is
// precisely what withholds the marker: a badge means "not here".
const row = document.querySelector<HTMLElement>('[data-thread-id="session-1"]');
expect(row?.dataset.machineId).toBe(THIS_MACHINE_ID);
expect(row?.dataset.machineOnline).toBeUndefined();
expect(row?.querySelector("[data-machine-marker-mode]")).toBeNull();
});

it("finds a remote-bound tab's own threads by that machine's name", async () => {
// The payoff of attributing the tab's own sessions. The scorer has always
// matched machine names; those entries simply had no machine to match,
// so with the tab bound to the Studio, typing "studio" found every
// thread on every OTHER machine and none of the ones right in front of
// you.
// A remote-bound tab keys its caches by the BINDING key, not the root
// path — see `selectActiveProjectStateKey`.
const boundKey = "remote:target-studio:project-a";
seedStore({
projectBinding: {
kind: "remote",
key: boundKey,
targetId: "target-studio",
runtimeName: "Arul's Mac Studio",
projectId: "project-a",
rootPath: PROJECT_ROOT,
displayName: "Repo A",
},
lanes: [makeLane()],
sessionsCacheByProject: {
[boundKey]: [makeSession({ id: "session-1", title: "Audit rebase settings" })],
},
workViewByProject: { [boundKey]: { activeItemId: null } },
crossMachineLanesByMachineId: {
"target-studio": makeForeignMachine({ online: false, sessions: [] }),
},
});

render(
<MemoryRouter>
<CommandPalette open onOpenChange={vi.fn()} />
</MemoryRouter>,
);

await screen.findByText("Recent threads");
fireEvent.change(
screen.getByPlaceholderText("Search commands, projects, and threads…"),
{ target: { value: "studio" } },
);

const row = await waitFor(() => {
const found = document.querySelector<HTMLElement>('[data-thread-id="session-1"]');
expect(found).toBeTruthy();
return found!;
});
// Found by machine, and marked as elsewhere — the tab points at the
// Studio but the app is running on this Mac.
expect(row.dataset.machineId).toBe("target-studio");
expect(row.dataset.machineOnline).toBe("false");
expect(row.dataset.dimmed).toBe("true");
expect(row.querySelector("[data-machine-marker-mode]")).toBeTruthy();
});

it("uses the bound target connection when its retained Work slice is absent", async () => {
const boundKey = "remote:target-studio:project-a";
seedStore({
projectBinding: {
kind: "remote",
key: boundKey,
targetId: "target-studio",
runtimeName: "Arul's Mac Studio",
projectId: "project-a",
rootPath: PROJECT_ROOT,
displayName: "Repo A",
},
lanes: [makeLane()],
sessionsCacheByProject: {
[boundKey]: [makeSession({ id: "session-1", title: "Audit rebase settings" })],
},
workViewByProject: { [boundKey]: { activeItemId: null } },
// The work-union refresh is still in flight, so it has not retained
// a lane slice for the bound target yet.
crossMachineLanesByMachineId: {},
});
globalThis.window.ade.remoteRuntime = {
getConnectionSnapshot: vi.fn(async () => ({
connectedCount: 0,
updatedAt: Date.now(),
connections: [{
target: { id: "target-studio", name: "Arul's Mac Studio" },
state: "error",
projects: [],
lastError: "Connection lost",
lastAttemptedAt: Date.now(),
connectedAt: null,
arch: null,
version: null,
}],
})),
onConnectionSnapshotChanged: vi.fn(() => () => {}),
} as any;

render(
<MemoryRouter>
<CommandPalette open onOpenChange={vi.fn()} />
</MemoryRouter>,
);

const row = await waitFor(() => {
const found = document.querySelector<HTMLElement>('[data-thread-id="session-1"]');
expect(found?.dataset.machineOnline).toBe("false");
return found!;
});
expect(row.dataset.dimmed).toBe("true");
expect(row.querySelector("[data-machine-marker-mode]")).toBeTruthy();
});
});
});
Expand Down
29 changes: 28 additions & 1 deletion apps/desktop/src/renderer/components/app/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,17 @@ export function CommandPalette({
// mount, and the palette is mounted for the whole session. Whatever the Work
// tab's sync has populated is what we search.
const foreignMachines = useRootAppStore((s) => s.crossMachineLanesByMachineId);
/**
* Which machine owns the tab's own sessions and lanes. Remote-bound tabs make
* that another Mac, and without saying so those threads look local: unmarked
* in the results, and unfindable by their machine's name.
*
* Memoized on the two primitives rather than on `projectBinding`, which is a
* fresh object across store writes and would rebuild the whole lowercased
* thread index on every one.
*/
const boundTargetId = projectBinding?.kind === "remote" ? projectBinding.targetId : null;
const boundRuntimeName = projectBinding?.kind === "remote" ? projectBinding.runtimeName : null;

const [mode, setMode] = useState<CommandPaletteMode>("default");
const [actionOutcome, setActionOutcome] =
Expand Down Expand Up @@ -339,6 +350,22 @@ export function CommandPalette({
},
[],
);
const activeMachine = useMemo(
() => {
if (!boundTargetId || !boundRuntimeName) return null;
const connection = remoteSnapshot?.connections.find(
(candidate) => candidate.target.id === boundTargetId,
);
return {
machineId: boundTargetId,
machineName: boundRuntimeName,
// A remote-bound tab has no local fallback: before its Work slice
// arrives, the target connection is the only honest liveness source.
online: connection?.state === "connected",
};
},
[boundRuntimeName, boundTargetId, remoteSnapshot],
);
const listRef = useRef<HTMLUListElement>(null);
const browseRequestRef = useRef(0);
const detailRequestRef = useRef(0);
Expand Down Expand Up @@ -835,7 +862,7 @@ export function CommandPalette({
// Built once per session/lane list, not per keystroke — the palette can be
// opened against hundreds of sessions and the lowercasing is the expensive
// half of the match.
const threadIndex = useThreadIndex(threadSessions, lanes, foreignMachines);
const threadIndex = useThreadIndex(threadSessions, lanes, foreignMachines, activeMachine);
const threadMatches = useMemo(
() =>
open && mode === "default" ? rankThreads(threadIndex, trimmedQuery) : [],
Expand Down
Loading