From 934ad219405a7b1180b7abc645b1850996ca333d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 03:03:50 -0400 Subject: [PATCH 01/14] Optimize usage settings mount Settings > Usage was forcing ade.usage.refresh on mount, which measured at roughly 6.5s in the perf pass project and made a settings tab switch kick off live provider quota polling. Hydrate the panel from the cached usage snapshot plus budget config instead, while keeping the manual Refresh button wired to the live usage refresh path. Add a renderer test that locks in the non-blocking mount behavior and verifies manual refresh still polls live data. Validation: npm --prefix apps/desktop run test -- src/renderer/components/settings/UsageGuardrailsSection.test.tsx; npm --prefix apps/desktop run typecheck. Live check against /Users/admin/Projects/perf pass showed usage mount calling ade.usage.getSnapshot/ade.usage.getBudgetConfig only, with no ade.usage.refresh spike. --- .../settings/UsageGuardrailsSection.test.tsx | 82 +++++++++++++++++++ .../settings/UsageGuardrailsSection.tsx | 2 +- 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.test.tsx diff --git a/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.test.tsx b/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.test.tsx new file mode 100644 index 000000000..1bccf4165 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.test.tsx @@ -0,0 +1,82 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { UsageSnapshot } from "../../../shared/types"; +import { UsageGuardrailsSection } from "./UsageGuardrailsSection"; + +function makeSnapshot(): UsageSnapshot { + return { + windows: [], + pacing: { + status: "on-track", + projectedWeeklyPercent: 0, + weekElapsedPercent: 0, + expectedPercent: 0, + deltaPercent: 0, + etaHours: null, + willLastToReset: true, + resetsInHours: 0, + }, + costs: [], + extraUsage: [], + lastPolledAt: "2026-05-08T07:00:00.000Z", + errors: [], + }; +} + +describe("UsageGuardrailsSection", () => { + const originalAde = globalThis.window.ade; + + beforeEach(() => { + globalThis.window.ade = { + usage: { + getSnapshot: vi.fn().mockResolvedValue(makeSnapshot()), + refresh: vi.fn().mockResolvedValue(makeSnapshot()), + getBudgetConfig: vi.fn().mockResolvedValue({}), + saveBudgetConfig: vi.fn().mockResolvedValue({}), + onUpdate: vi.fn(() => () => {}), + }, + ai: { + getStatus: vi.fn().mockResolvedValue({ + providerConnections: { + claude: null, + codex: null, + cursor: null, + droid: null, + }, + }), + }, + } as any; + }); + + afterEach(() => { + cleanup(); + globalThis.window.ade = originalAde; + }); + + it("hydrates from the cached snapshot on mount instead of forcing a live usage poll", async () => { + render(); + + await waitFor(() => { + expect(window.ade.usage.getSnapshot).toHaveBeenCalledTimes(1); + expect(window.ade.usage.getBudgetConfig).toHaveBeenCalledTimes(1); + }); + expect(window.ade.usage.refresh).not.toHaveBeenCalled(); + }); + + it("keeps live provider polling available through the manual refresh button", async () => { + render(); + + await waitFor(() => { + expect(window.ade.usage.getSnapshot).toHaveBeenCalledTimes(1); + }); + + fireEvent.click(screen.getByRole("button", { name: /refresh/i })); + + await waitFor(() => { + expect(window.ade.usage.refresh).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.tsx b/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.tsx index 8bb04ebee..8011eeb57 100644 --- a/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.tsx +++ b/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.tsx @@ -74,7 +74,7 @@ export function UsageGuardrailsSection({ setError(null); try { const [nextSnapshot, nextBudgetConfig] = await Promise.all([ - window.ade.usage.refresh(), + window.ade.usage.getSnapshot(), window.ade.usage.getBudgetConfig(), ]); setSnapshot(nextSnapshot); From 66a8c0bbcdf32bfc86c72f08e69d36d1643b5426 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 03:06:14 -0400 Subject: [PATCH 02/14] Defer PR rebase polling until needed The PR provider was loading rebase needs and auto-rebase statuses during ordinary GitHub PR list refreshes. In the earlier ADE-route sweep that path produced slow auto-rebase status calls, and the normal PR list cannot use that workflow state unless a PR detail or workflow tab is active. Gate rebase scans and auto-rebase status loads behind workflow tabs or a selected PR detail, while keeping event subscriptions active so runtime updates still flow in. Workflow routes still hydrate the same rebase state immediately. Validation: npm --prefix apps/desktop run test -- src/renderer/components/prs/state/PrsContext.test.tsx; npm --prefix apps/desktop run typecheck. Live check against /Users/admin/Projects/perf pass on /prs?tab=normal showed no ade.rebase.scanNeeds or ade.lanes.listAutoRebaseStatuses calls in the IPC summary. --- .../components/prs/state/PrsContext.test.tsx | 35 +++++++++------ .../components/prs/state/PrsContext.tsx | 44 +++++++++++++------ 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx index f5369e83d..ea8520aa2 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx @@ -50,6 +50,8 @@ function TabSwitchHarness() { describe("PrsContext refresh", () => { beforeEach(() => { + window.history.replaceState(null, "", "/"); + window.location.hash = ""; const refreshedNeed: RebaseNeed = { laneId: "lane-1", laneName: "Lane 1", @@ -82,19 +84,11 @@ describe("PrsContext refresh", () => { }, lanes: { list: vi.fn().mockResolvedValue([]), - listAutoRebaseStatuses: vi - .fn() - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]) - .mockResolvedValue([refreshedAutoStatus]), + listAutoRebaseStatuses: vi.fn().mockResolvedValue([refreshedAutoStatus]), onAutoRebaseEvent: vi.fn(() => () => {}), }, rebase: { - scanNeeds: vi - .fn() - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]) - .mockResolvedValue([refreshedNeed]), + scanNeeds: vi.fn().mockResolvedValue([refreshedNeed]), onEvent: vi.fn(() => () => {}), }, } as any; @@ -104,11 +98,10 @@ describe("PrsContext refresh", () => { cleanup(); globalThis.window.ade = originalAde; window.location.hash = ""; + window.history.replaceState(null, "", "/"); }); - it("refreshes rebase needs and auto-rebase statuses without waiting for events", async () => { - const user = userEvent.setup(); - + it("skips rebase scans for the plain GitHub PR list", async () => { render( @@ -118,8 +111,22 @@ describe("PrsContext refresh", () => { await waitFor(() => { expect(screen.getByTestId("loading").textContent).toBe("idle"); }); + expect(window.ade.rebase.scanNeeds).not.toHaveBeenCalled(); + expect(window.ade.lanes.listAutoRebaseStatuses).not.toHaveBeenCalled(); + }); + + it("refreshes rebase needs and auto-rebase statuses for workflow routes without waiting for events", async () => { + window.location.hash = "#/prs?tab=workflows&workflow=rebase&laneId=lane-1"; - await user.click(screen.getByRole("button", { name: "refresh" })); + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); await waitFor(() => { expect(screen.getByTestId("needs-count").textContent).toBe("1"); diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index 8a63f8234..940c372f8 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -674,20 +674,25 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { // kicks off another refresh instead of silently dropping the request. const applyLocalPrState = useCallback(async () => { const shouldLoadWorkflowState = activeTabRef.current !== "normal"; + const shouldLoadRebaseState = shouldLoadWorkflowState || selectedPrIdRef.current !== null; const [prList, laneList, queueStateList, refreshedRebaseNeeds, refreshedAutoRebaseStatuses] = await Promise.all([ window.ade.prs.listWithConflicts(), window.ade.lanes.list({ includeStatus: true }), shouldLoadWorkflowState ? window.ade.prs.listQueueStates({ includeCompleted: true, limit: 50 }) : Promise.resolve([] as QueueLandingState[]), - window.ade.rebase.scanNeeds().catch((err) => { - console.warn("[PrsContext] Failed to refresh rebase needs:", err); - return rebaseNeedsRef.current; - }), - window.ade.lanes.listAutoRebaseStatuses().catch((err) => { - console.warn("[PrsContext] Failed to refresh auto-rebase statuses:", err); - return autoRebaseStatusesRef.current; - }), + shouldLoadRebaseState + ? window.ade.rebase.scanNeeds().catch((err) => { + console.warn("[PrsContext] Failed to refresh rebase needs:", err); + return rebaseNeedsRef.current; + }) + : Promise.resolve(rebaseNeedsRef.current), + shouldLoadRebaseState + ? window.ade.lanes.listAutoRebaseStatuses().catch((err) => { + console.warn("[PrsContext] Failed to refresh auto-rebase statuses:", err); + return autoRebaseStatusesRef.current; + }) + : Promise.resolve(autoRebaseStatusesRef.current), ]); const changedPrIds = diffPrIds(prsRef.current, prList); @@ -1127,8 +1132,11 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { return unsub; }, []); - // Periodic rebase needs scan (cancelled flag guards against setState after unmount) + // Periodic rebase needs scan (cancelled flag guards against setState after unmount). + // The plain GitHub PR list does not render rebase workflow state, so avoid + // doing that git work until a workflow tab or selected PR detail can use it. useEffect(() => { + if (activeTab === "normal" && selectedPrId == null) return; let cancelled = false; const scan = () => { window.ade.rebase.scanNeeds().then((needs) => { @@ -1143,13 +1151,10 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { cancelled = true; clearInterval(timer); }; - }, []); + }, [activeTab, selectedPrId]); // Subscribe to auto-rebase events useEffect(() => { - window.ade.lanes.listAutoRebaseStatuses().then(setAutoRebaseStatuses).catch((err) => { - console.warn("[PrsContext] Failed to list auto-rebase statuses:", err); - }); const unsub = window.ade.lanes.onAutoRebaseEvent((event: AutoRebaseEventPayload) => { if (event.type === "auto-rebase-updated") { setAutoRebaseStatuses(event.statuses); @@ -1158,6 +1163,19 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { return unsub; }, []); + useEffect(() => { + if (activeTab === "normal" && selectedPrId == null) return; + let cancelled = false; + window.ade.lanes.listAutoRebaseStatuses().then((statuses) => { + if (!cancelled) setAutoRebaseStatuses(statuses); + }).catch((err) => { + console.warn("[PrsContext] Failed to list auto-rebase statuses:", err); + }); + return () => { + cancelled = true; + }; + }, [activeTab, selectedPrId]); + useEffect(() => { if (PRS_CONTEXT_CACHE_DISABLED) return; if (!initialLoadDone.current && prs.length === 0 && lanes.length === 0) return; From 294ef0b98931e2574e2fbbdd7985ad14f665e343 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 03:09:51 -0400 Subject: [PATCH 03/14] Stop idle top bar sync polling The top bar phone-sync indicator now loads status once for the active project, refreshes on focus, and relies on sync-status events for live updates instead of polling every five seconds on every route. Guard status requests with a local version counter so stale getStatus responses cannot overwrite newer sync events or focus refreshes. Add renderer coverage for the no-idle-poll behavior and for focus-triggered refreshes. --- .../renderer/components/app/TopBar.test.tsx | 43 +++++++++++++++++++ .../src/renderer/components/app/TopBar.tsx | 15 ++++--- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 97ff2fcde..2cf12252f 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -220,6 +220,49 @@ describe("TopBar", () => { expect(await screen.findByText("1 phone connected")).toBeTruthy(); }); + it("does not refresh phone sync status on an idle interval", async () => { + vi.useFakeTimers(); + try { + const getStatus = vi.fn(async () => makeSyncSnapshot()); + globalThis.window.ade.sync.getStatus = getStatus as any; + + render(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(getStatus).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(15_000); + await Promise.resolve(); + }); + + expect(getStatus).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("refreshes phone sync status when the window regains focus", async () => { + const getStatus = vi.fn() + .mockResolvedValueOnce(makeSyncSnapshot({ connectedPeers: [] })) + .mockResolvedValueOnce(makeSyncSnapshot()); + globalThis.window.ade.sync.getStatus = getStatus as any; + + render(); + + expect(await screen.findByText("Phone sync ready")).toBeTruthy(); + + await act(async () => { + window.dispatchEvent(new Event("focus")); + }); + + expect(await screen.findByText("1 phone connected")).toBeTruthy(); + expect(getStatus).toHaveBeenCalledTimes(2); + }); + it("shows project icon replacement errors", async () => { globalThis.window.ade.project.chooseIcon = vi.fn(async () => { throw new Error("Failed to set project icon: Project icon must be 10 MB or smaller."); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 6f534a798..7134085f3 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -540,6 +540,7 @@ export function TopBar() { useEffect(() => { let cancelled = false; + let statusRequestVersion = 0; if (!project?.rootPath) { setSyncSnapshot(null); setPhoneSyncOpen(false); @@ -548,31 +549,31 @@ export function TopBar() { }; } const refreshSyncStatus = () => { + const requestVersion = ++statusRequestVersion; void window.ade.sync.getStatus({ includeTransferReadiness: false }).then((snapshot) => { - if (!cancelled) setSyncSnapshot(snapshot); + if (!cancelled && requestVersion === statusRequestVersion) setSyncSnapshot(snapshot); }).catch(() => { - if (!cancelled) setSyncSnapshot(null); + if (!cancelled && requestVersion === statusRequestVersion) setSyncSnapshot(null); }); }; setSyncSnapshot(null); refreshSyncStatus(); - const interval = window.setInterval(refreshSyncStatus, 5_000); window.addEventListener("focus", refreshSyncStatus); const dispose = window.ade.sync.onEvent((event) => { if (!cancelled && event.type === "sync-status") { + statusRequestVersion += 1; setSyncSnapshot(event.snapshot); } }); return () => { cancelled = true; - window.clearInterval(interval); window.removeEventListener("focus", refreshSyncStatus); dispose(); }; // Background projects don't broadcast sync-status events (main.ts filters - // them to the active project), so we re-run this effect on rootPath - // change to force an immediate refetch instead of waiting up to 5s for - // the next polling tick. + // them to the active project), so we re-run this effect on rootPath change + // to force an immediate refetch. Focus refresh covers state changes that + // happen while ADE is not active. }, [project?.rootPath]); const checkForActiveWorkloads = useCallback(async (projectRootPath: string): Promise => { From dfa06bedfdec64855f67d866a3b1b1f3be4f03a3 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 03:14:11 -0400 Subject: [PATCH 04/14] Avoid GitHub refresh on plain PR mount The normal PR list now renders from local PR and lane state without kicking off ade.prs.refresh on mount. Workflow routes and selected PR deep links still keep the background GitHub refresh path because those surfaces depend on fresher workflow/detail state. Explicit user refresh still awaits ade.prs.refresh, and the tests now cover all three paths: plain mount skips it, workflow mount keeps it, and manual refresh runs it. Live perf-pass verification on /prs?tab=normal showed listWithConflicts/lanes.list without ade.prs.refresh in the IPC summary. --- .../components/prs/state/PrsContext.test.tsx | 27 +++++++++++++++++-- .../components/prs/state/PrsContext.tsx | 7 ++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx index ea8520aa2..edbc29d14 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx @@ -113,6 +113,7 @@ describe("PrsContext refresh", () => { }); expect(window.ade.rebase.scanNeeds).not.toHaveBeenCalled(); expect(window.ade.lanes.listAutoRebaseStatuses).not.toHaveBeenCalled(); + expect(window.ade.prs.refresh).not.toHaveBeenCalled(); }); it("refreshes rebase needs and auto-rebase statuses for workflow routes without waiting for events", async () => { @@ -132,6 +133,28 @@ describe("PrsContext refresh", () => { expect(screen.getByTestId("needs-count").textContent).toBe("1"); expect(screen.getByTestId("auto-count").textContent).toBe("1"); }); + expect(window.ade.prs.refresh).toHaveBeenCalledTimes(1); + }); + + it("runs a GitHub PR refresh for explicit refresh actions", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); + expect(window.ade.prs.refresh).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "refresh" })); + + await waitFor(() => { + expect(window.ade.prs.refresh).toHaveBeenCalledTimes(1); + }); }); it("does not run a GitHub PR refresh just because the local PR tab changes", async () => { @@ -146,14 +169,14 @@ describe("PrsContext refresh", () => { await waitFor(() => { expect(screen.getByTestId("loading").textContent).toBe("idle"); }); - expect(window.ade.prs.refresh).toHaveBeenCalledTimes(1); + expect(window.ade.prs.refresh).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: "queue" })); await waitFor(() => { expect(screen.getByTestId("active-tab").textContent).toBe("queue"); }); - expect(window.ade.prs.refresh).toHaveBeenCalledTimes(1); + expect(window.ade.prs.refresh).not.toHaveBeenCalled(); }); it("hydrates the Rebase/Merge workflow selection from the initial hash route", async () => { diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index 940c372f8..dbce541f5 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -795,7 +795,12 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { // Initial load useEffect(() => { - void refreshCore({ skipFreshWarmCache: true, githubRefreshMode: "background" }); + const shouldRefreshFromGithub = + activeTabRef.current !== "normal" || selectedPrIdRef.current !== null; + void refreshCore({ + skipFreshWarmCache: true, + githubRefreshMode: shouldRefreshFromGithub ? "background" : undefined, + }); }, [refreshCore]); // Silently refresh detail data for the given PR (no loading state). From 73972f4b4843ce10776d968bcb397675cd8d7d70 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 03:16:40 -0400 Subject: [PATCH 05/14] Use lightweight lanes on plain PR list The PR context now asks for undecorated lane summaries on the plain GitHub PR list, avoiding per-lane git status decoration when the route only needs lane names, colors, and linking metadata. Workflow routes and selected PR detail paths still request decorated lane status because those surfaces show rebase, behind, and merge guidance. Tests assert the includeStatus split, and live perf-pass verification dropped the normal PR lane-list IPC from about 74ms to about 12ms. --- .../src/renderer/components/prs/state/PrsContext.test.tsx | 2 ++ apps/desktop/src/renderer/components/prs/state/PrsContext.tsx | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx index edbc29d14..dca378cff 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx @@ -113,6 +113,7 @@ describe("PrsContext refresh", () => { }); expect(window.ade.rebase.scanNeeds).not.toHaveBeenCalled(); expect(window.ade.lanes.listAutoRebaseStatuses).not.toHaveBeenCalled(); + expect(window.ade.lanes.list).toHaveBeenCalledWith({ includeStatus: false }); expect(window.ade.prs.refresh).not.toHaveBeenCalled(); }); @@ -133,6 +134,7 @@ describe("PrsContext refresh", () => { expect(screen.getByTestId("needs-count").textContent).toBe("1"); expect(screen.getByTestId("auto-count").textContent).toBe("1"); }); + expect(window.ade.lanes.list).toHaveBeenCalledWith({ includeStatus: true }); expect(window.ade.prs.refresh).toHaveBeenCalledTimes(1); }); diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index dbce541f5..e026c29ec 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -675,9 +675,10 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { const applyLocalPrState = useCallback(async () => { const shouldLoadWorkflowState = activeTabRef.current !== "normal"; const shouldLoadRebaseState = shouldLoadWorkflowState || selectedPrIdRef.current !== null; + const shouldLoadDecoratedLaneStatus = shouldLoadWorkflowState || selectedPrIdRef.current !== null; const [prList, laneList, queueStateList, refreshedRebaseNeeds, refreshedAutoRebaseStatuses] = await Promise.all([ window.ade.prs.listWithConflicts(), - window.ade.lanes.list({ includeStatus: true }), + window.ade.lanes.list({ includeStatus: shouldLoadDecoratedLaneStatus }), shouldLoadWorkflowState ? window.ade.prs.listQueueStates({ includeCompleted: true, limit: 50 }) : Promise.resolve([] as QueueLandingState[]), From 87cd8d40884cc6c126ca160ba99b0bf666860011 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 03:21:47 -0400 Subject: [PATCH 06/14] Skip redundant graph preference writes Graph now marks the just-loaded preference snapshot as hydrated and skips the first persistence effect, so mounting the page does not immediately write the same state back to disk. The load path also resets the hydrated flag on project changes before reading the new root, preventing stale preferences from being persisted to the next project during the handoff. Validated with desktop typecheck and live perf-pass IPC logs: /graph now emits graph.state.get without the previous mount-time graph.state.set. --- .../components/graph/WorkspaceGraphPage.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index 2e5b1a7e9..b94af7904 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -259,6 +259,7 @@ function GraphInner() { const [viewMode, setViewMode] = React.useState("all"); const [sessionState, setSessionState] = React.useState(createSessionState); const [loadedGraphPreferences, setLoadedGraphPreferences] = React.useState(false); + const skipNextGraphPreferencePersistRootRef = React.useRef(null); const [nodes, setNodes] = React.useState>>([]); const [edges, setEdges] = React.useState>>([]); @@ -871,14 +872,20 @@ function GraphInner() { }, [refreshIntegrationProposals, reportGraphIssue]); React.useEffect(() => { - if (!project?.rootPath) return; + if (!project?.rootPath) { + setLoadedGraphPreferences(false); + skipNextGraphPreferencePersistRootRef.current = null; + return; + } const rootPath = project.rootPath; let cancelled = false; + setLoadedGraphPreferences(false); void window.ade.graphState .get(rootPath) .then((state) => { if (cancelled) return; const normalized = normalizeGraphPreferences(state); + skipNextGraphPreferencePersistRootRef.current = rootPath; setViewMode(normalized.preferences.lastViewMode); if (normalized.migrated) { void window.ade.graphState.set(rootPath, normalized.preferences).catch(() => {}); @@ -887,6 +894,7 @@ function GraphInner() { .catch((err) => { console.warn("[Graph] Failed to load graph state:", err); if (cancelled) return; + skipNextGraphPreferencePersistRootRef.current = rootPath; setViewMode(createGraphPreferences().lastViewMode); }) .finally(() => { @@ -899,6 +907,10 @@ function GraphInner() { React.useEffect(() => { if (!project?.rootPath || !loadedGraphPreferences) return; + if (skipNextGraphPreferencePersistRootRef.current === project.rootPath) { + skipNextGraphPreferencePersistRootRef.current = null; + return; + } void window.ade.graphState.set(project.rootPath, createGraphPreferences(viewMode)).catch(() => {}); }, [loadedGraphPreferences, project?.rootPath, viewMode]); From 5a6fda36eacf0e06b98deecc7a4399effa200ad9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 03:28:31 -0400 Subject: [PATCH 07/14] Defer Cursor model inventory during chat boot Use the cached AI status path to populate available chat models immediately, then run exact Cursor SDK inventory as a background refinement when Cursor is ready. Guard the background refinement with a refresh sequence so stale Cursor responses cannot overwrite a newer provider status refresh. Add a regression test where project config and AI status resolve while Cursor model inventory stays pending; chat boot must still leave the loading state. --- .../chat/AgentChatPane.submit.test.tsx | 67 +++++++++++++++++++ .../components/chat/AgentChatPane.tsx | 46 ++++++------- 2 files changed, 90 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index f8a5a9a2e..1c019e7b7 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -1060,6 +1060,73 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("does not block chat boot on Cursor model inventory after AI status resolves", async () => { + let resolveProjectConfig: (value: unknown) => void = () => {}; + const projectConfig = new Promise((resolve) => { + resolveProjectConfig = resolve; + }); + const cursorModels = new Promise(() => {}); + installAdeMocks({ + sessions: [], + }); + useAppStore.setState({ + project: { rootPath: "/tmp/project-under-test" } as any, + lanes: [{ + id: "lane-1", + name: "Lane 1", + laneType: "worktree", + branchRef: "refs/heads/lane-1", + worktreePath: "/tmp/project-under-test/lane-1", + } as any], + selectedLaneId: "lane-1", + }); + window.ade.projectConfig.get = vi.fn().mockReturnValue(projectConfig) as any; + window.ade.ai.getStatus = vi.fn().mockResolvedValue({ + mode: "subscription", + availableProviders: { claude: false, codex: true, cursor: true, droid: false }, + models: { claude: [], codex: [], cursor: [], droid: [] }, + features: [], + detectedAuth: [ + { type: "cli-subscription", cli: "codex", authenticated: true }, + { type: "api-key", provider: "cursor" }, + ], + availableModelIds: ["cursor/auto"], + }) as any; + window.ade.agentChat.models = vi.fn().mockImplementation(({ provider }: { provider: string }) => { + if (provider === "cursor") return cursorModels; + return Promise.resolve([]); + }) as any; + + render( + + + , + ); + + expect(await screen.findByText("Loading sessions")).toBeTruthy(); + + await act(async () => { + resolveProjectConfig({ + effective: { + ai: { + chat: { + sendOnEnter: true, + }, + }, + }, + }); + }); + + await waitFor(() => { + expect(screen.queryByText("Loading sessions")).toBeNull(); + }); + expect(await screen.findByText("Start a new conversation")).toBeTruthy(); + expect(window.ade.agentChat.models).toHaveBeenCalledWith({ + provider: "cursor", + activateRuntime: true, + }); + }); + it("keeps the committed model visible until the backend confirms the switch", async () => { const session = buildSession("session-1", { status: "idle" }); const sessions = [session]; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 41fab273c..f51ac2fd1 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1689,6 +1689,7 @@ export function AgentChatPane({ const [availableModelIds, setAvailableModelIds] = useState(() => seedAiStatus ? deriveConfiguredModelIds(seedAiStatus, { includeDroid: true }) : [], ); + const availableModelsRefreshSeqRef = useRef(0); const [claudePermissionMode, setClaudePermissionMode] = useState(initialNativeControls.claudePermissionMode); const [codexApprovalPolicy, setCodexApprovalPolicy] = useState(initialNativeControls.codexApprovalPolicy); const [codexSandbox, setCodexSandbox] = useState(initialNativeControls.codexSandbox); @@ -2693,6 +2694,7 @@ export function AgentChatPane({ }); const refreshAvailableModels = useCallback(async () => { + const refreshSeq = ++availableModelsRefreshSeqRef.current; const orderModelIds = (ids: Iterable): string[] => { const available = new Set(ids); const ordered = MODEL_REGISTRY @@ -2739,30 +2741,28 @@ export function AgentChatPane({ || status.providerConnections?.cursor?.runtimeAvailable === true; if (!cursorReady) return orderedAvailable; - let cursorModels: Awaited>; - try { - cursorModels = await getAgentChatModelsCached({ - projectRoot, - provider: "cursor", - activateRuntime: true, - }); - } catch { - return orderedAvailable; - } - if (!cursorModels.length) { - const withoutCursor = orderedAvailable.filter((id) => !isCursorModelId(id)); - setAvailableModelIds(withoutCursor); - return withoutCursor; - } + void getAgentChatModelsCached({ + projectRoot, + provider: "cursor", + activateRuntime: true, + }).then((cursorModels) => { + if (availableModelsRefreshSeqRef.current !== refreshSeq) return; + if (!cursorModels.length) { + const withoutCursor = orderedAvailable.filter((id) => !isCursorModelId(id)); + setAvailableModelIds(withoutCursor); + return; + } - const merged = new Set(available); - for (const model of cursorModels) { - const resolved = resolveCliRegistryModelId("cursor", model.id); - if (resolved) merged.add(resolved); - } - const withCursor = orderModelIds(merged); - setAvailableModelIds(withCursor); - return withCursor; + const merged = new Set(available); + for (const model of cursorModels) { + const resolved = resolveCliRegistryModelId("cursor", model.id); + if (resolved) merged.add(resolved); + } + const withCursor = orderModelIds(merged); + setAvailableModelIds(withCursor); + }).catch(() => undefined); + + return orderedAvailable; } catch { setAiStatus(null); setProviderConnections(null); From df639aca64a020cfe4e83eb80a7b3c239336357e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 03:36:04 -0400 Subject: [PATCH 08/14] Avoid auto-rebase status probe on lanes mount Pass the Lanes page snapshot auto-rebase status into the Git Actions pane so the pane can render existing attention state without immediately asking the main process for all lane statuses. Keep the older listAutoRebaseStatuses path as a delayed, cancellable fallback for call sites that do not provide a snapshot. Cover the mount behavior in the focused Git Actions pane test and keep conflict banner rendering immediate by providing the snapshot directly. --- .../lanes/LaneGitActionsPane.test.tsx | 6 +++- .../components/lanes/LaneGitActionsPane.tsx | 29 ++++++++++++++++--- .../renderer/components/lanes/LanesPage.tsx | 3 ++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx index bf33c6e68..f4dd90dab 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx @@ -226,6 +226,7 @@ describe("LaneGitActionsPane rescue action", () => { expect(window.ade.diff.getChanges).toHaveBeenCalledWith({ laneId: "lane-1" }); expect(window.ade.git.getSyncStatus).toHaveBeenCalledWith({ laneId: "lane-1" }); expect(window.ade.git.getSyncStatus).toHaveBeenCalledTimes(1); + expect(window.ade.lanes.listAutoRebaseStatuses).not.toHaveBeenCalled(); }); it("blocks pull and surfaces merge recovery actions when a merge is in progress", async () => { @@ -399,7 +400,10 @@ describe("LaneGitActionsPane rescue action", () => { }, ]; - renderPane({ onResolveRebaseConflict: resolveRebaseConflict }); + renderPane({ + autoRebaseStatusSnapshot: mockAutoRebaseStatuses[0] ?? null, + onResolveRebaseConflict: resolveRebaseConflict, + }); const rebaseTabButton = await screen.findByRole("button", { name: /open rebase\/merge tab/i }); screen.getByText("AUTO-REBASE FAILED"); diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 2425e7e86..9a72e56e1 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -452,6 +452,7 @@ function ActionButton({ export function LaneGitActionsPane({ laneId, autoRebaseEnabled, + autoRebaseStatusSnapshot, onOpenSettings, onRebaseNowLocal, onRebaseAndPush, @@ -467,6 +468,7 @@ export function LaneGitActionsPane({ }: { laneId: string | null; autoRebaseEnabled: boolean; + autoRebaseStatusSnapshot?: AutoRebaseLaneStatus | null; onOpenSettings: () => void; onRebaseNowLocal?: (laneId: string) => Promise | void; onRebaseAndPush?: (laneId: string) => Promise | void; @@ -517,7 +519,8 @@ export function LaneGitActionsPane({ const [commitTimelineKey, setCommitTimelineKey] = useState(0); const [amendCommit, setAmendCommit] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); - const [autoRebaseStatus, setAutoRebaseStatus] = useState(null); + const [autoRebaseStatus, setAutoRebaseStatus] = useState(autoRebaseStatusSnapshot ?? null); + const autoRebaseStatusSnapshotRef = useRef(autoRebaseStatusSnapshot); const [conflictState, setConflictState] = useState(null); const [stuckRebase, setStuckRebase] = useState(null); const laneGitActionRuntime = useLaneGitActionRuntimeState(laneId); @@ -709,6 +712,13 @@ export function LaneGitActionsPane({ } }, [projectRoot]); + useEffect(() => { + autoRebaseStatusSnapshotRef.current = autoRebaseStatusSnapshot; + if (autoRebaseStatusSnapshot !== undefined) { + setAutoRebaseStatus(autoRebaseStatusSnapshot); + } + }, [autoRebaseStatusSnapshot]); + const isNonFastForwardError = useCallback((rawMessage: string): boolean => { const lower = rawMessage.toLowerCase(); return lower.includes("non-fast-forward") || lower.includes("failed to push some refs"); @@ -838,7 +848,7 @@ export function LaneGitActionsPane({ setForcePushSuggested(false); setAmendCommit(false); setCommitMessageAi({ enabled: false, modelId: null }); - setAutoRebaseStatus(null); + setAutoRebaseStatus(autoRebaseStatusSnapshotRef.current ?? null); setConflictState(null); setStuckRebase(null); if (!laneId) return; @@ -848,9 +858,20 @@ export function LaneGitActionsPane({ error: err instanceof Error ? err.message : String(err), }); }); - void refreshAutoRebaseStatus(laneId); void refreshCommitMessageAiState(); - }, [laneId, lane?.branchRef, refreshAutoRebaseStatus, refreshCommitMessageAiState]); + }, [laneId, lane?.branchRef, refreshCommitMessageAiState]); + + useEffect(() => { + if (!laneId) return; + if (autoRebaseStatusSnapshotRef.current !== undefined) return; + const targetLaneId = laneId; + const timer = window.setTimeout(() => { + if (document.visibilityState !== "visible") return; + if (autoRebaseStatusSnapshotRef.current !== undefined) return; + void refreshAutoRebaseStatus(targetLaneId); + }, 3_500); + return () => window.clearTimeout(timer); + }, [laneId, lane?.branchRef, refreshAutoRebaseStatus]); useEffect(() => { if (!laneId) return; diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 2c71e8ff3..51774d118 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -2147,6 +2147,7 @@ export function LanesPage() { const getPaneConfigs = useCallback((laneId: string | null) => { const laneDetail = laneId ? lanePaneDetails[laneId] ?? EMPTY_LANE_PANE_DETAIL : EMPTY_LANE_PANE_DETAIL; + const laneSnapshot = laneId ? laneSnapshotByLaneId.get(laneId) ?? null : null; return { "git-actions": { title: "Git Actions", @@ -2180,6 +2181,7 @@ export function LanesPage() { runRebaseFlow(targetLaneId, "local_only")} onRebaseAndPush={(targetLaneId) => runRebaseFlow(targetLaneId, "local_and_remote")} @@ -2211,6 +2213,7 @@ export function LanesPage() { }; }, [ lanePaneDetails, + laneSnapshotByLaneId, expandedGitActionsLaneId, autoRebaseEnabled, openAutoRebaseSettings, From c5cbc4fee9ecb330aea9f107687dc4b367173d54 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 03:39:37 -0400 Subject: [PATCH 09/14] Skip redundant Cursor inventory when status has models Avoid starting the slower Cursor SDK model inventory when the AI status snapshot already includes Cursor model IDs for the picker. Keep the fallback inventory path for Cursor-ready statuses that do not include model IDs, and keep that path non-blocking for chat boot. Add focused chat tests for both cases so the optimization does not regress availability on sparse status snapshots. --- .../chat/AgentChatPane.submit.test.tsx | 44 ++++++++++++++++++- .../components/chat/AgentChatPane.tsx | 3 +- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index 1c019e7b7..bb6924f2f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -1090,7 +1090,7 @@ describe("AgentChatPane submit recovery", () => { { type: "cli-subscription", cli: "codex", authenticated: true }, { type: "api-key", provider: "cursor" }, ], - availableModelIds: ["cursor/auto"], + availableModelIds: [], }) as any; window.ade.agentChat.models = vi.fn().mockImplementation(({ provider }: { provider: string }) => { if (provider === "cursor") return cursorModels; @@ -1127,6 +1127,48 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("uses Cursor model IDs from AI status without probing Cursor inventory", async () => { + installAdeMocks({ + sessions: [], + }); + useAppStore.setState({ + project: { rootPath: "/tmp/project-under-test" } as any, + lanes: [{ + id: "lane-1", + name: "Lane 1", + laneType: "worktree", + branchRef: "refs/heads/lane-1", + worktreePath: "/tmp/project-under-test/lane-1", + } as any], + selectedLaneId: "lane-1", + }); + window.ade.ai.getStatus = vi.fn().mockResolvedValue({ + mode: "subscription", + availableProviders: { claude: false, codex: true, cursor: true, droid: false }, + models: { claude: [], codex: [], cursor: [], droid: [] }, + features: [], + detectedAuth: [ + { type: "cli-subscription", cli: "codex", authenticated: true }, + { type: "api-key", provider: "cursor" }, + ], + availableModelIds: ["cursor/auto"], + }) as any; + window.ade.agentChat.models = vi.fn().mockResolvedValue([]) as any; + + render( + + + , + ); + + expect(await screen.findByText("Start a new conversation")).toBeTruthy(); + await waitFor(() => { + expect(window.ade.ai.getStatus).toHaveBeenCalled(); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(window.ade.agentChat.models).not.toHaveBeenCalled(); + }); + it("keeps the committed model visible until the backend confirms the switch", async () => { const session = buildSession("session-1", { status: "idle" }); const sessions = [session]; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index f51ac2fd1..c935a3102 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -2739,7 +2739,8 @@ export function AgentChatPane({ setAvailableModelIds(orderedAvailable); const cursorReady = status.availableProviders?.cursor === true || status.providerConnections?.cursor?.runtimeAvailable === true; - if (!cursorReady) return orderedAvailable; + const hasCursorModelIds = orderedAvailable.some(isCursorModelId); + if (!cursorReady || hasCursorModelIds) return orderedAvailable; void getAgentChatModelsCached({ projectRoot, From ca8ad67fb8f91e53be875f2ef1a725bae916c86c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 11:35:20 -0400 Subject: [PATCH 10/14] Add model catalog hook and cursor refresh Expose an onModelCatalogOpen/onOpen callback on the model selector to notify when the model catalog is opened and trigger a cursor-model inventory refresh. Refactor model ordering and cursor checks into orderAvailableModelIds and isCursorModelId helpers. Add refreshCursorModelInventory to lazily fetch and merge cursor provider models (activating runtime) and update availableModelIds accordingly. Wire the new hook into AgentChatComposer and AgentChatPane (including handoff model selector) and remove the previous inline cursor-fetch/ordering logic. --- .ade/.gitignore | 49 +- apps/ade-cli/src/cli.test.ts | 52 +- apps/ade-cli/src/cli.ts | 14 +- .../automationPlannerService.test.ts | 110 +++++ .../automations/automationPlannerService.ts | 71 ++- .../automations/automationService.test.ts | 453 +++++++++++++++++- .../services/automations/automationService.ts | 88 +++- ...tConfigService.automationExecution.test.ts | 121 ++++- .../services/config/projectConfigService.ts | 31 +- .../src/renderer/components/app/AppShell.tsx | 6 + .../src/renderer/components/app/TopBar.tsx | 14 +- .../components/automations/ActionList.tsx | 5 +- .../components/automations/ActionRow.tsx | 195 +++++++- .../automations/GitHubTriggerFilters.tsx | 31 ++ .../automations/LinearTriggerFilters.tsx | 21 +- .../components/automations/RulesTab.tsx | 96 +++- .../components/RuleEditorPanel.tsx | 387 ++++++++++++++- .../components/chat/AgentChatComposer.tsx | 4 + .../chat/AgentChatPane.submit.test.tsx | 16 +- .../components/chat/AgentChatPane.tsx | 103 ++-- .../components/prs/state/PrsContext.tsx | 3 +- .../shared/ProviderModelSelector.tsx | 3 + apps/desktop/src/shared/types/automations.ts | 1 + apps/desktop/src/shared/types/config.ts | 9 +- docs/ARCHITECTURE.md | 6 +- docs/features/chat/composer-and-ui.md | 9 +- docs/features/lanes/README.md | 2 +- .../onboarding-and-settings/README.md | 6 +- docs/features/pull-requests/README.md | 1 + .../features/terminals-and-sessions/README.md | 11 +- docs/features/workspace-graph/README.md | 7 + 31 files changed, 1744 insertions(+), 181 deletions(-) diff --git a/.ade/.gitignore b/.ade/.gitignore index d93703639..ed1aa1c15 100644 --- a/.ade/.gitignore +++ b/.ade/.gitignore @@ -1,32 +1,19 @@ -# Machine-local ADE state -local.yaml -local.secret.yaml -ade.db -ade.db-* -ade.db-wal -embeddings.db -ade.sock -artifacts/ -transcripts/ -cache/ -worktrees/ -secrets/ +# ADE ignores local runtime state by default. +* -# Local-only generated runtime docs/state -agents/ -cto/CURRENT.md -cto/MEMORY.md -cto/core-memory.json -cto/daily/ -cto/sessions.jsonl -cto/subordinate-activity.jsonl -cto/openclaw-history.json -cto/openclaw-idempotency.json -cto/openclaw-outbox.json -cto/openclaw-routes.json -cto/openclaw-device.json -context/ -memory/ -history/ -reflections/ -context/*.ade.md +# Shared ADE project config +!.gitignore +!ade.yaml +!cto/ +!cto/identity.yaml + +# Shared user-authored ADE assets +!templates/ +!templates/** +!skills/ +!skills/** +!workflows/ +!workflows/linear/ +!workflows/linear/** +!project-icons/ +!project-icons/** diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 8c340f74d..171ef96e1 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -1515,6 +1515,43 @@ describe("ADE CLI", () => { }); }); + it("automations create accepts require-on-trigger lane mode without a target lane", () => { + const plan = buildCliPlan([ + "automations", + "create", + "--text", + "id: r1\n", + "--lane-mode", + "require-on-trigger", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + args: { + draft: { + execution: { laneMode: "require-on-trigger" }, + }, + }, + }, + }); + }); + + it("automations create rejects --lane with --lane-mode require-on-trigger", () => { + expect(() => + buildCliPlan([ + "automations", + "create", + "--text", + "id: r1\n", + "--lane-mode", + "require-on-trigger", + "--lane", + "lane-1", + ]), + ).toThrow(/--lane is only valid with --lane-mode reuse/); + }); + it("automations create with --lane-name-preset custom accepts --lane-name-template", () => { const plan = buildCliPlan([ "automations", @@ -1602,7 +1639,7 @@ describe("ADE CLI", () => { "--lane-mode", "bogus", ]), - ).toThrow(/--lane-mode must be one of create, reuse/); + ).toThrow(/--lane-mode must be one of create, reuse, require-on-trigger/); }); it("automations runs accepts a --status filter", () => { @@ -1731,6 +1768,19 @@ describe("ADE CLI", () => { }); }); + it("automations trigger aliases run and forwards --lane as laneId", () => { + const plan = buildCliPlan(["automations", "trigger", "rule-42", "--lane", "lane-7"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + domain: "automations", + action: "triggerManually", + args: { id: "rule-42", laneId: "lane-7" }, + }, + }); + }); + it("automations runs passes through --rule and --limit as filters", () => { const plan = buildCliPlan([ "automations", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 8f60afdd0..2e5aec050 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1209,13 +1209,15 @@ const HELP_BY_COMMAND: Record = { $ ade automations update --from-file $ ade automations delete Remove a local rule $ ade automations toggle --enabled true|false - $ ade automations run [--dry-run] Trigger a rule manually + $ ade automations run [--lane ] [--dry-run] + $ ade automations trigger --lane + Trigger a rule manually $ ade automations runs [--rule ] [--status ] [--limit 50] $ ade automations run-show [--json] Inspect a run $ ade automations example Print an example rule (stdout) Lane mode flags (apply to create/update on top of --from-file/--stdin/--text): - --lane-mode Spawn a new lane per run, or reuse one + --lane-mode Create, reuse, or require lane at trigger time --lane Target lane (only with --lane-mode reuse) --lane-name-preset --lane-name-template Template (only with preset custom) @@ -3706,7 +3708,7 @@ function parseDraftInput(args: string[]): JsonObject { return parsed; } -const AUTOMATION_LANE_MODES = ["create", "reuse"] as const; +const AUTOMATION_LANE_MODES = ["create", "reuse", "require-on-trigger"] as const; const AUTOMATION_LANE_NAME_PRESETS = ["issue-title", "issue-num-title", "pr-title-author", "custom"] as const; const AUTOMATION_RUN_STATUSES = ["queued", "running", "succeeded", "failed", "cancelled", "paused", "all"] as const; @@ -3737,10 +3739,10 @@ function applyLaneFlagsToDraft(draft: JsonObject, args: string[]): JsonObject { return draft; } - if (laneId != null && laneMode === "create") { + if (laneId != null && laneMode != null && laneMode !== "reuse") { throw new CliUsageError("--lane is only valid with --lane-mode reuse."); } - if (preset != null && laneMode === "reuse") { + if (preset != null && laneMode != null && laneMode !== "create") { throw new CliUsageError("--lane-name-preset is only valid with --lane-mode create."); } if (template != null && preset != null && preset !== "custom") { @@ -3867,7 +3869,7 @@ function buildAutomationsPlan(args: string[]): CliPlan { }; } - if (sub === "run") { + if (sub === "run" || sub === "trigger") { const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); const dryRun = readFlag(args, ["--dry-run"]); const laneId = readLaneId(args); diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.test.ts b/apps/desktop/src/main/services/automations/automationPlannerService.test.ts index 27a077bb0..456f19693 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.test.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.test.ts @@ -363,6 +363,86 @@ describe("automationPlannerService.validateDraft", () => { expect((res.normalized?.actions[2] as any).targetLaneId).toBe("lane-conflict"); }); + it("preserves require-on-trigger lane mode without requiring a target lane", () => { + const { planner } = getPlanner({ suites: [] }); + const draft = createDraft({ + name: "Require trigger lane", + execution: { kind: "agent-session", laneMode: "require-on-trigger" } as any, + prompt: "Use the lane supplied by the caller.", + }); + + const res = planner.validateDraft({ draft, confirmations: [] }); + expect(res.ok).toBe(true); + expect(res.normalized?.execution).toMatchObject({ + kind: "agent-session", + laneMode: "require-on-trigger", + }); + }); + + it("normalizes legacy prompt-at-run lane mode to require-on-trigger", () => { + const { planner } = getPlanner({ suites: [] }); + const draft = createDraft({ + name: "Legacy prompt at run", + execution: { kind: "agent-session", laneMode: "prompt-at-run" } as any, + prompt: "Use the selected lane.", + }); + + const res = planner.validateDraft({ draft, confirmations: [] }); + expect(res.ok).toBe(true); + expect(res.normalized?.execution).toMatchObject({ + kind: "agent-session", + laneMode: "require-on-trigger", + }); + }); + + it("rejects targetLaneId when lane mode requires the trigger lane", () => { + const { planner } = getPlanner({ suites: [] }); + const draft = createDraft({ + name: "Conflicting trigger lane", + execution: { + kind: "agent-session", + laneMode: "require-on-trigger", + targetLaneId: "lane-fixed", + } as any, + prompt: "This should choose at trigger time.", + }); + + const res = planner.validateDraft({ draft, confirmations: [] }); + expect(res.ok).toBe(false); + expect(res.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + level: "error", + path: "execution.targetLaneId", + }), + ]), + ); + }); + + it("rejects per-action targetLaneId when lane mode requires the trigger lane", () => { + const { planner } = getPlanner({ suites: [] }); + const draft = createDraft({ + name: "Conflicting step lane", + execution: { + kind: "built-in", + laneMode: "require-on-trigger", + } as any, + actions: [{ type: "run-command", command: "pwd", targetLaneId: "lane-fixed" } as any], + legacyActions: [{ type: "run-command", command: "pwd", targetLaneId: "lane-fixed" } as any], + }); + + const res = planner.validateDraft({ draft, confirmations: ["confirm.run-command"] }); + expect(res.ok).toBe(false); + expect(res.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + level: "error", + path: "actions[0].targetLaneId", + }), + ]), + ); + }); + it("validates run-command cwd against the per-action targetLaneId before draft execution lane", () => { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-planner-action-lane-")); const actionLane = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-planner-action-lane-target-")); @@ -396,6 +476,36 @@ describe("automationPlannerService.validateDraft", () => { fs.rmSync(draftLane, { recursive: true, force: true }); } }); + + it("preserves output disposition and verification settings", () => { + const { planner } = getPlanner({ suites: [] }); + const draft = createDraft({ + name: "Publish settings", + execution: { kind: "agent-session" } as any, + prompt: "Prepare a draft PR.", + outputs: { + disposition: "open-pr-draft", + createArtifact: false, + notificationChannel: "automation-alerts", + }, + verification: { + verifyBeforePublish: true, + mode: "dry-run", + }, + }); + + const res = planner.validateDraft({ draft, confirmations: [] }); + expect(res.ok).toBe(true); + expect(res.normalized?.outputs).toMatchObject({ + disposition: "open-pr-draft", + createArtifact: false, + notificationChannel: "automation-alerts", + }); + expect(res.normalized?.verification).toMatchObject({ + verifyBeforePublish: true, + mode: "dry-run", + }); + }); }); function createDraft( diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index dca34f417..bb3d7bc6c 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -78,6 +78,34 @@ function safeTrim(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +function normalizeLaneMode(value: unknown): NonNullable["laneMode"] | undefined { + const raw = safeTrim(value); + if (raw === "provided" || raw === "prompt-at-run") return "require-on-trigger"; + return raw === "create" || raw === "reuse" || raw === "require-on-trigger" ? raw : undefined; +} + +function normalizeLaneNamePreset(value: unknown): NonNullable["laneNamePreset"] | undefined { + const raw = safeTrim(value); + return raw === "issue-title" || raw === "issue-num-title" || raw === "pr-title-author" || raw === "custom" + ? raw + : undefined; +} + +function normalizeOutputDisposition(value: unknown): AutomationRule["outputs"]["disposition"] { + const raw = safeTrim(value); + return raw === "open-task" || + raw === "open-lane" || + raw === "prepare-patch" || + raw === "open-pr-draft" || + raw === "comment-only" + ? raw + : "comment-only"; +} + +function normalizeVerificationMode(value: unknown): NonNullable { + return safeTrim(value) === "dry-run" ? "dry-run" : "intervention"; +} + function extractFirstJsonObject(text: string): string | null { const raw = text.trim(); if (!raw) return null; @@ -797,10 +825,40 @@ function normalizeDraft(args: { } const requestedExecution = args.draft.execution; + const requestedLaneMode = normalizeLaneMode(requestedExecution?.laneMode); + const requestedLaneNamePreset = normalizeLaneNamePreset(requestedExecution?.laneNamePreset); + const requestedLaneNameTemplate = requestedLaneNamePreset === "custom" + ? safeTrim(requestedExecution?.laneNameTemplate) + : ""; + if (requestedLaneMode === "require-on-trigger" && safeTrim(requestedExecution?.targetLaneId)) { + issues.push({ + level: "error", + path: "execution.targetLaneId", + message: "targetLaneId is not allowed when lane must be supplied at trigger time.", + }); + } + if (requestedLaneMode === "require-on-trigger") { + normalizedActions.forEach((action, index) => { + if (!safeTrim(action.targetLaneId)) return; + issues.push({ + level: "error", + path: `actions[${index}].targetLaneId`, + message: "Step lane overrides are ignored when lane must be supplied at trigger time.", + }); + }); + } + const laneExecutionFields = { + ...(requestedLaneMode ? { laneMode: requestedLaneMode } : {}), + ...(requestedLaneMode === "create" && requestedLaneNamePreset ? { laneNamePreset: requestedLaneNamePreset } : {}), + ...(requestedLaneMode === "create" && requestedLaneNamePreset === "custom" && requestedLaneNameTemplate + ? { laneNameTemplate: requestedLaneNameTemplate } + : {}), + }; const execution = requestedExecution?.kind === "agent-session" || requestedExecution?.kind === "mission" || requestedExecution?.kind === "built-in" ? { kind: requestedExecution.kind, + ...laneExecutionFields, ...(safeTrim(requestedExecution.targetLaneId) ? { targetLaneId: safeTrim(requestedExecution.targetLaneId) } : {}), ...(requestedExecution.kind === "agent-session" ? { @@ -822,8 +880,8 @@ function normalizeDraft(args: { ...(requestedExecution.kind === "built-in" ? { builtIn: { actions: normalizedActions } } : {}), } : normalizedActions.length > 0 - ? { kind: "built-in" as const, builtIn: { actions: normalizedActions } } - : { kind: "agent-session" as const, session: {} }; + ? { kind: "built-in" as const, ...laneExecutionFields, builtIn: { actions: normalizedActions } } + : { kind: "agent-session" as const, ...laneExecutionFields, session: {} }; if (execution.kind === "built-in" && normalizedActions.length === 0) { issues.push({ @@ -903,13 +961,13 @@ function normalizeDraft(args: { ...(args.draft.guardrails?.activeHours ? { activeHours: args.draft.guardrails.activeHours } : {}), }, outputs: { - disposition: "comment-only", + disposition: normalizeOutputDisposition(args.draft.outputs?.disposition), ...(typeof args.draft.outputs?.createArtifact === "boolean" ? { createArtifact: args.draft.outputs.createArtifact } : { createArtifact: true }), ...(safeTrim(args.draft.outputs?.notificationChannel) ? { notificationChannel: safeTrim(args.draft.outputs?.notificationChannel) } : {}), }, verification: { - verifyBeforePublish: false, - mode: "intervention", + verifyBeforePublish: Boolean(args.draft.verification?.verifyBeforePublish), + mode: normalizeVerificationMode(args.draft.verification?.mode), }, billingCode: safeTrim(args.draft.billingCode) || `auto:${slugify(name)}`, includeProjectContext, @@ -1349,6 +1407,9 @@ export function createAutomationPlannerService({ if (execution.kind === "built-in") { notes.push("Built-in tasks run directly without launching a mission or chat thread."); } + if (execution.laneMode === "require-on-trigger") { + notes.push("Lane resolution: trigger caller must supply a lane."); + } return { normalized, actions, notes, issues }; } diff --git a/apps/desktop/src/main/services/automations/automationService.test.ts b/apps/desktop/src/main/services/automations/automationService.test.ts index cfee67633..826c90447 100644 --- a/apps/desktop/src/main/services/automations/automationService.test.ts +++ b/apps/desktop/src/main/services/automations/automationService.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { createRequire } from "node:module"; import initSqlJs from "sql.js"; import type { Database, SqlJsStatic } from "sql.js"; -import { createAutomationService, presetToTemplate, triggerMatches } from "./automationService"; +import { createAutomationService, normalizeRuntimeRule, presetToTemplate, triggerMatches } from "./automationService"; type SqlValue = string | number | null | Uint8Array; @@ -78,6 +78,33 @@ describe("triggerMatches", () => { }); }); +describe("normalizeRuntimeRule", () => { + it("normalizes legacy prompt-at-run lane mode to require-on-trigger", () => { + const normalized = normalizeRuntimeRule({ + id: "legacy-prompt-at-run", + name: "Legacy prompt at run", + enabled: true, + mode: "review", + triggers: [{ type: "manual" }], + trigger: { type: "manual" }, + execution: { kind: "agent-session", laneMode: "prompt-at-run" } as any, + executor: { mode: "automation-bot" }, + prompt: "Run in the supplied lane.", + reviewProfile: "quick", + toolPalette: ["repo"], + contextSources: [], + memory: { mode: "none" }, + guardrails: {}, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" }, + billingCode: "auto:test", + actions: [], + }); + + expect(normalized.execution?.laneMode).toBe("require-on-trigger"); + }); +}); + function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { const raw = new SQL.Database(); raw.run(` @@ -125,6 +152,32 @@ function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { output text ) `); + raw.run(` + create table automation_ingress_events( + id text primary key, + project_id text not null, + source text not null, + event_key text not null, + automation_ids_json text not null, + trigger_type text not null, + event_name text, + status text not null, + summary text, + error_message text, + cursor text, + raw_payload_json text, + received_at text not null + ) + `); + raw.run(` + create table automation_ingress_cursors( + project_id text not null, + source text not null, + cursor text, + updated_at text not null, + primary key(project_id, source) + ) + `); const run = (sql: string, params: SqlValue[] = []) => raw.run(sql, params); const all = = Record>(sql: string, params: SqlValue[] = []): T[] => @@ -294,6 +347,171 @@ describe("automationService integration", () => { } }); + it("requires manual triggers to pass laneId when laneMode is require-on-trigger", async () => { + const { db } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-require-lane-")); + + const rule = { + id: "manual-require-lane", + name: "Manual require lane", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + execution: { + kind: "built-in" as const, + laneMode: "require-on-trigger" as const, + builtIn: { actions: [{ type: "run-command" as const, command: "pwd", timeoutMs: 10_000 }] }, + }, + actions: [{ type: "run-command" as const, command: "pwd", timeoutMs: 10_000 }], + enabled: true + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [{ id: "lane-primary", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService + }); + + try { + await expect(service.triggerManually({ id: "manual-require-lane" })).rejects.toThrow(/requires a lane/); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("uses the supplied laneId for require-on-trigger manual runs", async () => { + const { db, raw } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-require-project-")); + const suppliedLaneRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-require-supplied-")); + const actionLaneRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-require-action-")); + + const rule = { + id: "manual-supplied-lane", + name: "Manual supplied lane", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + execution: { + kind: "built-in" as const, + laneMode: "require-on-trigger" as const, + builtIn: { actions: [{ type: "run-command" as const, command: "pwd", targetLaneId: "lane-action", timeoutMs: 10_000 }] }, + }, + actions: [{ type: "run-command" as const, command: "pwd", targetLaneId: "lane-action", timeoutMs: 10_000 }], + enabled: true + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [{ id: "lane-primary", laneType: "primary" }, { id: "lane-supplied", laneType: "worktree" }, { id: "lane-action", laneType: "worktree" }], + getLaneWorktreePath: (laneId: string) => laneId === "lane-supplied" ? suppliedLaneRoot : laneId === "lane-action" ? actionLaneRoot : projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService + }); + + try { + const run = await service.triggerManually({ id: "manual-supplied-lane", laneId: "lane-supplied" }); + expect(run.status).toBe("succeeded"); + const mapped = mapExecRows(raw.exec("select output from automation_action_results")); + expect(String(mapped[0]?.output ?? "")).toContain(suppliedLaneRoot); + expect(String(mapped[0]?.output ?? "")).not.toContain(actionLaneRoot); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(suppliedLaneRoot, { recursive: true, force: true }); + fs.rmSync(actionLaneRoot, { recursive: true, force: true }); + } + }); + + it("fails non-manual require-on-trigger runs when the event has no lane", async () => { + const { db, raw } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-require-event-")); + + const rule = { + id: "event-require-lane", + name: "Event require lane", + trigger: { type: "github.issue_opened" as const }, + triggers: [{ type: "github.issue_opened" as const }], + execution: { + kind: "built-in" as const, + laneMode: "require-on-trigger" as const, + builtIn: { actions: [{ type: "run-command" as const, command: "pwd", timeoutMs: 10_000 }] }, + }, + actions: [{ type: "run-command" as const, command: "pwd", timeoutMs: 10_000 }], + enabled: true + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [{ id: "lane-primary", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService + }); + + try { + const event = await service.dispatchIngressTrigger({ + source: "github-polling", + eventKey: "issue:require-lane", + triggerType: "github.issue_opened", + eventName: "github.issue_opened", + issue: { number: 1, title: "No lane", labels: [] }, + } as any); + expect(event?.status).toBe("dispatched"); + const runs = mapExecRows(raw.exec("select status, error_message from automation_runs where automation_id = 'event-require-lane'")); + expect(runs[0]?.status).toBe("failed"); + expect(String(runs[0]?.error_message ?? "")).toContain("trigger payload to include a laneId"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + it("launches mission automations on the configured target lane", async () => { const { db } = createInMemoryAdeDb(); const logger = createLogger(); @@ -455,6 +673,163 @@ describe("automationService integration", () => { } }); + it("uses the step lane override when a built-in action launches a mission", async () => { + const { db } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-built-in-mission-lane-")); + const createMission = vi.fn(() => ({ + id: "mission-built-in-lane", + status: "in_progress", + outcomeSummary: null, + completedAt: null, + lastError: null, + })); + + const rule = { + id: "built-in-mission-lane", + name: "Built-in mission lane", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + executor: { mode: "automation-bot", targetId: null }, + toolPalette: [] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" as const }, + billingCode: "auto:test", + execution: { + kind: "built-in" as const, + targetLaneId: "lane-rule", + builtIn: { actions: [{ type: "launch-mission" as const, sessionTitle: "Follow-up mission", targetLaneId: "lane-action" }] }, + }, + actions: [{ type: "launch-mission" as const, sessionTitle: "Follow-up mission", targetLaneId: "lane-action" }], + prompt: "Run a mission from a built-in action.", + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [ + { id: "lane-primary", laneType: "primary" }, + { id: "lane-rule", laneType: "worktree" }, + { id: "lane-action", laneType: "worktree" }, + ], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + missionService: { + create: createMission, + patchMetadata: vi.fn(), + } as any, + aiOrchestratorService: { + startMissionRun: vi.fn(async () => undefined), + } as any, + }); + + try { + await service.triggerManually({ id: "built-in-mission-lane" }); + expect(createMission).toHaveBeenCalledWith(expect.objectContaining({ laneId: "lane-action" })); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("fails built-in launch-mission actions when required trigger lane is missing", async () => { + const { db, raw } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-built-in-mission-require-lane-")); + const createMission = vi.fn(); + + const rule = { + id: "built-in-mission-require-lane", + name: "Built-in mission require lane", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "github.issue_opened" as const }, + triggers: [{ type: "github.issue_opened" as const }], + executor: { mode: "automation-bot", targetId: null }, + toolPalette: [] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" as const }, + billingCode: "auto:test", + execution: { + kind: "built-in" as const, + laneMode: "require-on-trigger" as const, + builtIn: { actions: [{ type: "launch-mission" as const, sessionTitle: "Follow-up mission" }] }, + }, + actions: [{ type: "launch-mission" as const, sessionTitle: "Follow-up mission" }], + prompt: "Run a mission from a built-in action.", + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [{ id: "lane-primary", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + missionService: { + create: createMission, + patchMetadata: vi.fn(), + } as any, + aiOrchestratorService: { + startMissionRun: vi.fn(async () => undefined), + } as any, + }); + + try { + await service.dispatchIngressTrigger({ + source: "github-polling", + eventKey: "issue:built-in-mission-require-lane", + triggerType: "github.issue_opened", + eventName: "github.issue_opened", + issue: { number: 1, title: "No lane", labels: [] }, + } as any); + expect(createMission).not.toHaveBeenCalled(); + const runs = mapExecRows(raw.exec("select status, error_message from automation_runs where automation_id = 'built-in-mission-require-lane'")); + expect(runs[0]?.status).toBe("failed"); + expect(String(runs[0]?.error_message ?? "")).toContain("trigger payload to include a laneId"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + it("attaches built-in agent-session actions to the automation run", async () => { const { db } = createInMemoryAdeDb(); const logger = createLogger(); @@ -1376,6 +1751,82 @@ describe("automationService integration", () => { } }); + it("reuses the created lane for every built-in step in the run", async () => { + const { db, raw, logger, projectId, projectRoot } = buildLaneModeFixtures(); + const laneRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-lane-mode-created-")); + const createLane = vi.fn(async ({ name }: { name: string }) => ({ + id: "lane-fresh", + name, + branchRef: name.replace(/\s+/g, "-").toLowerCase(), + laneType: "feature", + worktreePath: laneRoot, + })); + + const rule = { + id: "built-in-create-lane", + name: "Built-in create lane", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + executor: { mode: "automation-bot", targetId: null }, + toolPalette: [] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" as const }, + billingCode: "auto:test", + execution: { + kind: "built-in" as const, + laneMode: "create" as const, + laneNamePreset: "issue-title" as const, + builtIn: { + actions: [ + { type: "run-command" as const, command: "pwd", timeoutMs: 10_000 }, + { type: "run-command" as const, command: "pwd", timeoutMs: 10_000 }, + ], + }, + }, + actions: [], + }; + + const projectConfigService = { + get: () => ({ trust: { requiresSharedTrust: false }, effective: { automations: [rule], providerMode: "guest" } }) + } as any; + const laneService = { + create: createLane, + list: async () => [{ id: "lane-primary", name: "primary", laneType: "primary" }], + getLaneWorktreePath: (laneId: string) => laneId === "lane-fresh" ? laneRoot : projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + }); + + try { + const run = await service.triggerManually({ id: "built-in-create-lane" }); + expect(run.status).toBe("succeeded"); + expect(createLane).toHaveBeenCalledTimes(1); + const commandRows = mapExecRows(raw.exec("select output from automation_action_results where action_type = 'run-command' order by action_index asc")); + expect(commandRows).toHaveLength(2); + expect(String(commandRows[0]?.output ?? "")).toContain(laneRoot); + expect(String(commandRows[1]?.output ?? "")).toContain(laneRoot); + const setupRows = mapExecRows(raw.exec("select status from automation_action_results where action_type = 'lane-setup'")); + expect(setupRows).toHaveLength(1); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(laneRoot, { recursive: true, force: true }); + } + }); + it("appends issue number on collision then a random suffix on a second collision", async () => { const { db, logger, projectId, projectRoot } = buildLaneModeFixtures(); const createLane = vi.fn(async ({ name }: { name: string }) => ({ diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 733239bcf..4a2510f66 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -618,6 +618,11 @@ function deriveIncludeProjectContext(rule: AutomationRule): boolean { return false; } +function normalizeAutomationLaneMode(mode: unknown): AutomationExecution["laneMode"] | undefined { + if (mode === "provided" || mode === "prompt-at-run") return "require-on-trigger"; + return mode === "create" || mode === "reuse" || mode === "require-on-trigger" ? mode : undefined; +} + export function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { const triggers = normalizedRuleTriggers(rule).map(canonicalizeTriggerForRuntime); const legacyActions = Array.isArray(rule.legacy?.actions) @@ -630,8 +635,9 @@ export function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { const rawExecution = rule.execution ?? (legacyActions.length > 0 ? { kind: "built-in" as const, builtIn: { actions: legacyActions } } : { kind: "mission" as const }); + const laneMode = normalizeAutomationLaneMode(rawExecution.laneMode); const sharedLaneFields = { - ...(rawExecution.laneMode ? { laneMode: rawExecution.laneMode } : {}), + ...(laneMode ? { laneMode } : {}), ...(rawExecution.laneNamePreset ? { laneNamePreset: rawExecution.laneNamePreset } : {}), ...(rawExecution.laneNamePreset === "custom" && rawExecution.laneNameTemplate ? { laneNameTemplate: rawExecution.laneNameTemplate } @@ -660,9 +666,9 @@ export function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { ...(rawExecution.mission ? { mission: rawExecution.mission } : {}), }; const outputDisposition = rule.outputs?.disposition ?? "comment-only"; - // Silently drop per-rule budget/review fields that are no longer surfaced - // in the UI. We keep them in the on-disk YAML so a downgrade doesn't lose - // data, but the in-memory runtime shape zeroes them out. + // Per-rule budget fields are deprecated in favor of global usage caps. We + // keep them in YAML for downgrade compatibility, but do not let runtime + // consume them. const sanitizedGuardrails = { ...(rule.guardrails ?? {}) } as AutomationRule["guardrails"] & { budgetCapUsd?: number; maxSpendUsd?: number; @@ -1405,6 +1411,26 @@ export function createAutomationService({ ); }; + const loadLaneSetupLaneId = (runId: string | null | undefined): string | null => { + if (!runId) return null; + const row = db.get<{ output: string | null }>( + ` + select output + from automation_action_results + where project_id = ? + and run_id = ? + and action_type = 'lane-setup' + and status = 'succeeded' + order by started_at asc + limit 1 + `, + [projectId, runId] + ); + const parsed = safeJsonParse | null>(row?.output, null); + if (!isRecord(parsed)) return null; + return trimToNull(parsed.laneId); + }; + const loadRunRow = (runId: string): AutomationRunRow | null => db.get( ` select @@ -1693,6 +1719,14 @@ export function createAutomationService({ return trimToNull(action?.targetLaneId) ?? trimToNull(rule.execution?.targetLaneId); }; + const requiresTriggerLane = (rule: AutomationRule): boolean => + rule.execution?.laneMode === "require-on-trigger"; + + const missingTriggerLaneMessage = (trigger: Pick): string => + trigger.triggerType === "manual" + ? "This automation requires a lane when triggered manually. Pass laneId / --lane." + : "This automation requires the trigger payload to include a laneId."; + const dispatchAdeAction = async ( config: RunAdeActionConfig, trigger: TriggerContext, @@ -1761,7 +1795,10 @@ export function createAutomationService({ } if (action.type === "predict-conflicts") { if (!conflictService) throw new Error("Conflict service unavailable"); - await conflictService.runPrediction(trigger.laneId ? { laneId: trigger.laneId } : {}); + const laneId = requiresTriggerLane(rule) || rule.execution?.laneMode === "create" + ? await resolveExecutionLaneId(rule, trigger, action, runId) + : trigger.laneId; + await conflictService.runPrediction(laneId ? { laneId } : {}); return { status: "succeeded" }; } if (action.type === "create-lane") { @@ -1802,11 +1839,13 @@ export function createAutomationService({ if (!testService) throw new Error("Test service unavailable"); const activeLanes = await laneService.list({ includeArchived: false }); const configuredLaneId = getConfiguredTargetLaneId(rule, action); - const laneId = configuredLaneId - ?? trigger.laneId - ?? activeLanes.find((lane) => lane.laneType === "primary")?.id - ?? activeLanes[0]?.id - ?? null; + const laneId = requiresTriggerLane(rule) || rule.execution?.laneMode === "create" + ? await resolveExecutionLaneId(rule, trigger, action, runId) + : configuredLaneId + ?? trigger.laneId + ?? activeLanes.find((lane) => lane.laneType === "primary")?.id + ?? activeLanes[0]?.id + ?? null; if (!laneId) throw new Error("No lane available to run tests"); await testService.run({ laneId, suiteId }); return { status: "succeeded" }; @@ -1881,11 +1920,19 @@ export function createAutomationService({ } } if (action.type === "launch-mission") { + const laneMode = rule.execution?.laneMode; + const actionTargetLaneId = trimToNull(action.targetLaneId); + const ruleTargetLaneId = trimToNull(rule.execution?.targetLaneId); const missionRule: AutomationRule = { ...rule, execution: { kind: "mission", - ...(rule.execution?.targetLaneId ? { targetLaneId: rule.execution.targetLaneId } : {}), + ...(laneMode ? { laneMode } : {}), + ...(laneMode === "create" && rule.execution?.laneNamePreset ? { laneNamePreset: rule.execution.laneNamePreset } : {}), + ...(laneMode === "create" && rule.execution?.laneNameTemplate ? { laneNameTemplate: rule.execution.laneNameTemplate } : {}), + ...(laneMode !== "require-on-trigger" && (actionTargetLaneId ?? ruleTargetLaneId) + ? { targetLaneId: actionTargetLaneId ?? ruleTargetLaneId } + : {}), mission: { title: action.sessionTitle?.trim() || rule.execution?.mission?.title || null }, }, }; @@ -1905,7 +1952,9 @@ export function createAutomationService({ if (action.type === "run-command") { const command = (action.command ?? "").trim(); if (!command) throw new Error("run-command requires command"); - const laneId = getConfiguredTargetLaneId(rule, action) ?? trigger.laneId; + const laneId = requiresTriggerLane(rule) || rule.execution?.laneMode === "create" + ? await resolveExecutionLaneId(rule, trigger, action, runId) + : getConfiguredTargetLaneId(rule, action) ?? trigger.laneId; const baseCwd = laneId ? laneService.getLaneWorktreePath(laneId) : projectRoot; const configuredCwd = (action.cwd ?? "").trim(); const cwdCandidate = configuredCwd.length @@ -2123,9 +2172,18 @@ export function createAutomationService({ runId?: string | null, ): Promise => { const actionLaneId = trimToNull(action?.targetLaneId); + if (requiresTriggerLane(rule)) { + const triggerLaneId = trimToNull(trigger.laneId); + if (triggerLaneId) return triggerLaneId; + throw new Error(missingTriggerLaneMessage(trigger)); + } + if (actionLaneId) return actionLaneId; if (rule.execution?.laneMode === "create") { + const existingCreatedLaneId = loadLaneSetupLaneId(runId); + if (existingCreatedLaneId) return existingCreatedLaneId; + const setupActionId = runId ? insertAction(runId, -1, "lane-setup") : null; try { const { laneId, laneName } = await createLaneForRun(rule, trigger); @@ -3278,9 +3336,13 @@ export function createAutomationService({ if (!id) throw new Error("Automation id is required"); const rule = findRule(id); if (!rule) throw new Error(`Automation not found: ${id}`); + const laneId = typeof args.laneId === "string" && args.laneId.trim().length ? args.laneId.trim() : undefined; + if (requiresTriggerLane(rule) && !laneId) { + throw new Error(missingTriggerLaneMessage({ triggerType: "manual" })); + } return await runRule(rule, { triggerType: "manual", - laneId: typeof args.laneId === "string" && args.laneId.trim().length ? args.laneId.trim() : undefined, + laneId, reason: id, scheduledAt: nowIso(), reviewProfileOverride: args.reviewProfileOverride ?? null, diff --git a/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts b/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts index 1c5eda9ca..e9dba7be9 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts @@ -73,6 +73,22 @@ describe("projectConfigService automation execution normalization", () => { laneNameTemplate: "Should be dropped", }, }, + { + id: "require-trigger-lane-rule", + trigger: { type: "manual" }, + execution: { + kind: "agent-session", + laneMode: "require-on-trigger", + }, + }, + { + id: "legacy-prompt-at-run-rule", + trigger: { type: "manual" }, + execution: { + kind: "agent-session", + laneMode: "prompt-at-run", + }, + }, ], }), "utf8", @@ -86,7 +102,7 @@ describe("projectConfigService automation execution normalization", () => { logger: makeLogger(), }); - const [customRule, presetRule] = service.get().effective.automations; + const [customRule, presetRule, requireTriggerLaneRule, legacyPromptAtRunRule] = service.get().effective.automations; expect(customRule.execution).toMatchObject({ kind: "mission", @@ -101,5 +117,108 @@ describe("projectConfigService automation execution normalization", () => { laneNamePreset: "issue-title", }); expect(presetRule.execution?.laneNameTemplate).toBeUndefined(); + expect(requireTriggerLaneRule.execution).toMatchObject({ + kind: "agent-session", + laneMode: "require-on-trigger", + }); + expect(legacyPromptAtRunRule.execution).toMatchObject({ + kind: "agent-session", + laneMode: "require-on-trigger", + }); + }); + + it("flags fixed target lanes on require-on-trigger automation execution", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-automation-execution-")); + tempDirs.push(root); + + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-automation-execution-validation", + db: makeDb(), + logger: makeLogger(), + }); + + const validation = service.validate({ + shared: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }, + local: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [ + { + id: "bad-trigger-lane", + name: "Bad trigger lane", + enabled: true, + mode: "review", + trigger: { type: "manual" }, + triggers: [{ type: "manual" }], + execution: { + kind: "agent-session", + laneMode: "require-on-trigger", + targetLaneId: "lane-fixed", + }, + executor: { mode: "automation-bot" }, + prompt: "Run.", + reviewProfile: "quick", + toolPalette: ["repo"], + contextSources: [], + memory: { mode: "none" }, + guardrails: {}, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" }, + billingCode: "auto:test", + }, + { + id: "bad-step-lane", + name: "Bad step lane", + enabled: true, + mode: "review", + trigger: { type: "manual" }, + triggers: [{ type: "manual" }], + execution: { + kind: "built-in", + laneMode: "require-on-trigger", + builtIn: { + actions: [{ type: "run-command", command: "pwd", targetLaneId: "lane-fixed" }], + }, + }, + executor: { mode: "automation-bot" }, + reviewProfile: "quick", + toolPalette: ["repo"], + contextSources: [], + memory: { mode: "none" }, + guardrails: {}, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" }, + billingCode: "auto:test", + }, + ], + }, + } as any); + + expect(validation.ok).toBe(false); + expect(validation.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "effective.automations[0].execution.targetLaneId", + }), + expect.objectContaining({ + path: "effective.automations[1].execution.builtIn.actions[0].targetLaneId", + }), + ]), + ); }); }); diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index e6a6470c7..0aaf1b8a0 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -477,6 +477,7 @@ function coerceAutomationAction(value: unknown): AutomationAction | null { if (!type) return null; const out: AutomationAction = { type }; + const targetLaneId = asString(value.targetLaneId)?.trim(); const suiteId = asString(value.suiteId); const command = asString(value.command); const cwd = asString(value.cwd); @@ -488,6 +489,7 @@ function coerceAutomationAction(value: unknown): AutomationAction | null { const prompt = asString(value.prompt); const sessionTitle = asString(value.sessionTitle); + if (targetLaneId) out.targetLaneId = targetLaneId; if (suiteId != null) out.suiteId = suiteId; if (command != null) out.command = command; if (cwd != null) out.cwd = cwd; @@ -527,8 +529,10 @@ function coerceAutomationExecution(value: unknown): AutomationExecution | undefi const targetLaneId = asString(value.targetLaneId)?.trim() || undefined; const laneModeRaw = asString(value.laneMode)?.trim(); - const laneMode: AutomationExecution["laneMode"] = laneModeRaw === "create" || laneModeRaw === "reuse" + const laneMode: AutomationExecution["laneMode"] = laneModeRaw === "create" || laneModeRaw === "reuse" || laneModeRaw === "require-on-trigger" ? laneModeRaw + : laneModeRaw === "provided" || laneModeRaw === "prompt-at-run" + ? "require-on-trigger" : undefined; const laneNamePresetRaw = asString(value.laneNamePreset)?.trim(); const laneNamePreset: AutomationExecution["laneNamePreset"] = laneNamePresetRaw === "issue-title" || @@ -2348,6 +2352,7 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF ...(typeof entry.includeProjectContext === "boolean" ? { includeProjectContext: entry.includeProjectContext } : {}), actions: (entry.actions ?? []).map((action) => ({ type: action.type, + ...(action.targetLaneId ? { targetLaneId: action.targetLaneId.trim() } : {}), ...(action.suiteId ? { suiteId: action.suiteId.trim() } : {}), ...(action.command ? { command: action.command } : {}), ...(action.cwd ? { cwd: action.cwd.trim() } : {}), @@ -2364,6 +2369,7 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF ...(entry.actions ? { actions: entry.actions.map((action) => ({ type: action.type, + ...(action.targetLaneId ? { targetLaneId: action.targetLaneId.trim() } : {}), ...(action.suiteId ? { suiteId: action.suiteId.trim() } : {}), ...(action.command ? { command: action.command } : {}), ...(action.cwd ? { cwd: action.cwd.trim() } : {}), @@ -2921,6 +2927,29 @@ function validateEffectiveConfig( } else if (rule.execution.kind === "built-in" && !(rule.execution.builtIn?.actions?.length ?? 0)) { issues.push({ path: `${p}.execution.builtIn.actions`, message: "Built-in automations need at least one task." }); } + if ( + rule.execution?.laneMode + && rule.execution.laneMode !== "create" + && rule.execution.laneMode !== "reuse" + && rule.execution.laneMode !== "require-on-trigger" + ) { + issues.push({ path: `${p}.execution.laneMode`, message: `Unknown lane mode '${String(rule.execution.laneMode)}'` }); + } + if (rule.execution?.laneMode === "require-on-trigger" && (rule.execution.targetLaneId ?? "").trim()) { + issues.push({ + path: `${p}.execution.targetLaneId`, + message: "targetLaneId is not allowed when lane must be supplied at trigger time.", + }); + } + if (rule.execution?.laneMode === "require-on-trigger" && rule.execution.kind === "built-in") { + rule.execution.builtIn?.actions.forEach((action, actionIndex) => { + if (!(action.targetLaneId ?? "").trim()) return; + issues.push({ + path: `${p}.execution.builtIn.actions[${actionIndex}].targetLaneId`, + message: "Step lane overrides are not allowed when lane must be supplied at trigger time.", + }); + }); + } if ( rule.execution?.kind && diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 5cfe2699f..160ade8ea 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -370,10 +370,16 @@ export function AppShell({ children }: { children: React.ReactNode }) { const currentProjectRoot = useAppStore.getState().project?.rootPath ?? null; const currentShowWelcome = useAppStore.getState().showWelcome; + const currentIsNewTabOpen = useAppStore.getState().isNewTabOpen; const hasStoredProject = Boolean(nextProject); const projectChanged = nextProjectRoot !== currentProjectRoot; const welcomeChanged = currentShowWelcome === hasStoredProject; + if (currentIsNewTabOpen && nextProject && !projectChanged) { + setProject(nextProject); + return; + } + if (nextProject) { setProject(nextProject); setShowWelcome(false); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 7134085f3..0bcc41378 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -846,7 +846,7 @@ export function TopBar() { onDrop={(e) => handleDrop(e, idx)} onDragEnd={handleDragEnd} className={cn( - "ade-shell-project-tab group inline-flex w-[clamp(128px,16vw,220px)] max-w-[220px] shrink-0 items-center gap-2 px-3 py-0.5", + "ade-shell-project-tab group inline-flex w-[clamp(128px,16vw,220px)] max-w-[220px] min-w-0 shrink-0 items-center gap-2 px-3 py-0.5", "transition-[background-color,color,border-color,box-shadow,opacity] duration-150", !isMissing && "cursor-pointer", isCurrent && "font-semibold", @@ -895,14 +895,14 @@ export function TopBar() { ) : null} {rp.displayName} {isMissing ? ( - + + + + + + ) : null} ); } diff --git a/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx b/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx index 505704387..3dbcbe201 100644 --- a/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx +++ b/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx @@ -29,7 +29,11 @@ import type { AutomationDraftIssue, AutomationLaneMode, AutomationLaneNamePreset, + AutomationMode, + AutomationOutputDisposition, + AutomationReviewProfile, AutomationRuleDraft, + AutomationToolFamily, AutomationTrigger, TestSuiteDefinition, } from "../../../../shared/types"; @@ -124,6 +128,40 @@ const SCHEDULE_PRESETS: Array<{ label: string; cron: string }> = [ { label: "Fridays at 4 PM", cron: "0 16 * * 5" }, ]; +const REVIEW_PROFILES: Array<{ value: AutomationReviewProfile; label: string }> = [ + { value: "quick", label: "Quick" }, + { value: "incremental", label: "Incremental" }, + { value: "full", label: "Full" }, + { value: "security", label: "Security" }, + { value: "release-risk", label: "Release risk" }, + { value: "cross-repo-contract", label: "Cross-repo contract" }, +]; + +const RULE_MODES: Array<{ value: AutomationMode; label: string }> = [ + { value: "review", label: "Review" }, + { value: "fix", label: "Fix" }, + { value: "monitor", label: "Monitor" }, +]; + +const TOOL_FAMILIES: Array<{ value: AutomationToolFamily; label: string }> = [ + { value: "repo", label: "Repo" }, + { value: "git", label: "Git" }, + { value: "tests", label: "Tests" }, + { value: "github", label: "GitHub" }, + { value: "linear", label: "Linear" }, + { value: "browser", label: "Browser" }, + { value: "memory", label: "Memory" }, + { value: "mission", label: "Mission" }, +]; + +const OUTPUT_DISPOSITIONS: Array<{ value: AutomationOutputDisposition; label: string }> = [ + { value: "comment-only", label: "Comment only" }, + { value: "open-task", label: "Open task" }, + { value: "open-lane", label: "Open lane" }, + { value: "prepare-patch", label: "Prepare patch" }, + { value: "open-pr-draft", label: "Open PR draft" }, +]; + const LANE_NAME_PRESETS: Array<{ value: AutomationLaneNamePreset; label: string; @@ -136,6 +174,8 @@ const LANE_NAME_PRESETS: Array<{ { value: "custom", label: "Custom template…", template: "", helpEvent: "any" }, ]; +type DraftLaneMode = AutomationLaneMode | (string & {}); + function presetTemplate(preset: AutomationLaneNamePreset, customTemplate: string | undefined): string { if (preset === "custom") return customTemplate ?? ""; return LANE_NAME_PRESETS.find((p) => p.value === preset)?.template ?? ""; @@ -225,6 +265,23 @@ function triggerFamilyForType(type: AutomationTrigger["type"]): TriggerFamily { return "manual"; } +function readLaneMode(draft: AutomationRuleDraft): DraftLaneMode { + const raw = (draft.execution as { laneMode?: unknown } | undefined)?.laneMode; + return typeof raw === "string" && raw.trim() ? (raw.trim() as DraftLaneMode) : "reuse"; +} + +function isRequireLaneAtRunTimeMode(mode: DraftLaneMode | null | undefined): boolean { + return mode === "require-on-trigger" || mode === "provided" || mode === "prompt-at-run"; +} + +function humanizeLaneMode(mode: string): string { + return mode + .split(/[-_]/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + function defaultTriggerForFamily(family: TriggerFamily): AutomationTrigger { switch (family) { case "github": @@ -268,6 +325,60 @@ function computeIncludeProjectContext(draft: AutomationRuleDraft): boolean { // --- draft <-> ActionRow[] bridge --- +type ActionRowRuntimeOptions = Pick; +type AutomationActionRuntimeOptions = Pick; + +function actionRuntimeOptions(action: AutomationAction): Partial { + return { + ...(action.targetLaneId ? { targetLaneId: action.targetLaneId } : {}), + ...(action.condition ? { condition: action.condition } : {}), + ...(typeof action.continueOnFailure === "boolean" ? { continueOnFailure: action.continueOnFailure } : {}), + ...(Number.isFinite(action.timeoutMs) ? { timeoutMs: action.timeoutMs } : {}), + ...(Number.isFinite(action.retry) ? { retry: action.retry } : {}), + }; +} + +function rowRuntimeOptions(row: ActionRowValue): Partial { + return { + ...(row.targetLaneId ? { targetLaneId: row.targetLaneId } : {}), + ...(row.condition?.trim() ? { condition: row.condition.trim() } : {}), + ...(row.continueOnFailure ? { continueOnFailure: true } : {}), + ...(Number.isFinite(row.timeoutMs) ? { timeoutMs: row.timeoutMs } : {}), + ...(Number.isFinite(row.retry) ? { retry: row.retry } : {}), + }; +} + +function rowHasRuntimeOptions(row: ActionRowValue): boolean { + return Boolean( + row.targetLaneId + || row.condition?.trim() + || row.continueOnFailure + || Number.isFinite(row.timeoutMs) + || Number.isFinite(row.retry), + ); +} + +function stripActionTargetLaneId(action: T): Omit { + const { targetLaneId: _targetLaneId, ...rest } = action; + return rest; +} + +function stripActionTargetLaneIdsFromDraft(draft: AutomationRuleDraft): AutomationRuleDraft { + const execution = draft.execution ? { ...draft.execution } : undefined; + if (execution) delete execution.targetLaneId; + if (execution?.kind === "built-in") { + execution.builtIn = { + actions: (execution.builtIn?.actions ?? []).map((action) => stripActionTargetLaneId(action) as AutomationAction), + }; + } + return { + ...draft, + ...(execution ? { execution } : {}), + actions: draft.actions.map((action) => stripActionTargetLaneId(action) as AutomationRuleDraft["actions"][number]), + legacyActions: draft.legacyActions?.map((action) => stripActionTargetLaneId(action) as AutomationRuleDraft["actions"][number]), + }; +} + function draftToActionRows(draft: AutomationRuleDraft): ActionRowValue[] { const rows: ActionRowValue[] = []; const execution = draft.execution; @@ -290,15 +401,16 @@ function draftToActionRows(draft: AutomationRuleDraft): ActionRowValue[] { laneNameTemplate: action.laneNameTemplate ?? "", laneDescriptionTemplate: action.laneDescriptionTemplate ?? "", parentLaneId: action.parentLaneId ?? null, + ...actionRuntimeOptions(action), }); } else if (action.type === "run-tests") { - rows.push({ kind: "run-tests", suiteId: action.suiteId ?? "" }); + rows.push({ kind: "run-tests", suiteId: action.suiteId ?? "", ...actionRuntimeOptions(action) }); } else if (action.type === "run-command") { - rows.push({ kind: "run-command", command: action.command ?? "", cwd: action.cwd ?? "" }); + rows.push({ kind: "run-command", command: action.command ?? "", cwd: action.cwd ?? "", ...actionRuntimeOptions(action) }); } else if (action.type === "predict-conflicts") { - rows.push({ kind: "predict-conflicts" }); + rows.push({ kind: "predict-conflicts", ...actionRuntimeOptions(action) }); } else if (action.type === "ade-action") { - rows.push({ kind: "ade-action", adeAction: action.adeAction ?? { domain: "", action: "" } }); + rows.push({ kind: "ade-action", adeAction: action.adeAction ?? { domain: "", action: "" }, ...actionRuntimeOptions(action) }); } else if (action.type === "agent-session") { rows.push({ kind: "agent-session", @@ -306,9 +418,10 @@ function draftToActionRows(draft: AutomationRuleDraft): ActionRowValue[] { sessionTitle: action.sessionTitle ?? "", modelConfig: action.modelConfig, permissionConfig: action.permissionConfig, + ...actionRuntimeOptions(action), }); } else if (action.type === "launch-mission") { - rows.push({ kind: "launch-mission", missionTitle: action.sessionTitle ?? "" }); + rows.push({ kind: "launch-mission", missionTitle: action.sessionTitle ?? "", ...actionRuntimeOptions(action) }); } } } @@ -316,17 +429,20 @@ function draftToActionRows(draft: AutomationRuleDraft): ActionRowValue[] { } function applyActionRowsToDraft(draft: AutomationRuleDraft, rows: ActionRowValue[]): AutomationRuleDraft { - const soloAgent = rows.length === 1 && rows[0]!.kind === "agent-session"; - const soloMission = rows.length === 1 && rows[0]!.kind === "launch-mission"; + const rowsForSave = isRequireLaneAtRunTimeMode(readLaneMode(draft)) + ? rows.map((row) => stripActionTargetLaneId(row) as ActionRowValue) + : rows; + const soloAgent = rowsForSave.length === 1 && rowsForSave[0]!.kind === "agent-session" && !rowHasRuntimeOptions(rowsForSave[0]!); + const soloMission = rowsForSave.length === 1 && rowsForSave[0]!.kind === "launch-mission" && !rowHasRuntimeOptions(rowsForSave[0]!); if (soloAgent) { - const first = rows[0]!; + const first = rowsForSave[0]!; return { ...draft, execution: { ...(draft.execution ?? { kind: "agent-session" }), kind: "agent-session", - session: { title: first.sessionTitle || null }, + session: { ...(draft.execution?.kind === "agent-session" ? draft.execution.session : {}), title: first.sessionTitle || null }, }, ...(first.modelConfig ? { modelConfig: { orchestratorModel: first.modelConfig } } : {}), ...(first.permissionConfig ? { permissionConfig: first.permissionConfig } : {}), @@ -337,20 +453,20 @@ function applyActionRowsToDraft(draft: AutomationRuleDraft, rows: ActionRowValue } if (soloMission) { - const first = rows[0]!; + const first = rowsForSave[0]!; return { ...draft, execution: { ...(draft.execution ?? { kind: "mission" }), kind: "mission", - mission: { title: first.missionTitle || null }, + mission: { ...(draft.execution?.kind === "mission" ? draft.execution.mission : {}), title: first.missionTitle || null }, }, actions: [], legacyActions: [], }; } - const builtInActions: AutomationAction[] = rows.map((row) => rowToAutomationAction(row)); + const builtInActions: AutomationAction[] = rowsForSave.map((row) => rowToAutomationAction(row)); const legacyDraftActions: AutomationRuleDraft["actions"] = builtInActions .map((action) => automationActionToDraftAction(action)) .filter((entry): entry is AutomationRuleDraft["actions"][number] => entry != null); @@ -373,28 +489,32 @@ function rowToAutomationAction(row: ActionRowValue): AutomationAction { case "create-lane": return { type: "create-lane", + ...rowRuntimeOptions(row), ...(row.laneNameTemplate ? { laneNameTemplate: row.laneNameTemplate } : {}), ...(row.laneDescriptionTemplate ? { laneDescriptionTemplate: row.laneDescriptionTemplate } : {}), ...(row.parentLaneId ? { parentLaneId: row.parentLaneId } : {}), }; case "run-tests": - return { type: "run-tests", suiteId: row.suiteId ?? "" }; + return { type: "run-tests", ...rowRuntimeOptions(row), suiteId: row.suiteId ?? "" }; case "run-command": return { type: "run-command", + ...rowRuntimeOptions(row), command: row.command ?? "", ...(row.cwd ? { cwd: row.cwd } : {}), }; case "predict-conflicts": - return { type: "predict-conflicts" }; + return { type: "predict-conflicts", ...rowRuntimeOptions(row) }; case "ade-action": return { type: "ade-action", + ...rowRuntimeOptions(row), adeAction: row.adeAction ?? { domain: "", action: "" }, }; case "agent-session": return { type: "agent-session", + ...rowRuntimeOptions(row), ...(row.modelConfig ? { modelConfig: row.modelConfig } : {}), ...(row.permissionConfig ? { permissionConfig: row.permissionConfig } : {}), ...(row.prompt ? { prompt: row.prompt } : {}), @@ -403,6 +523,7 @@ function rowToAutomationAction(row: ActionRowValue): AutomationAction { case "launch-mission": return { type: "launch-mission", + ...rowRuntimeOptions(row), ...(row.missionTitle ? { sessionTitle: row.missionTitle } : {}), }; } @@ -415,28 +536,32 @@ function automationActionToDraftAction( case "create-lane": return { type: "create-lane", + ...rowRuntimeOptions(actionToRow(action)), ...(action.laneNameTemplate ? { laneNameTemplate: action.laneNameTemplate } : {}), ...(action.laneDescriptionTemplate ? { laneDescriptionTemplate: action.laneDescriptionTemplate } : {}), ...(action.parentLaneId ? { parentLaneId: action.parentLaneId } : {}), }; case "run-tests": - return { type: "run-tests", suite: action.suiteId ?? "" }; + return { type: "run-tests", ...rowRuntimeOptions(actionToRow(action)), suite: action.suiteId ?? "" }; case "run-command": return { type: "run-command", + ...rowRuntimeOptions(actionToRow(action)), command: action.command ?? "", ...(action.cwd ? { cwd: action.cwd } : {}), }; case "predict-conflicts": - return { type: "predict-conflicts" }; + return { type: "predict-conflicts", ...rowRuntimeOptions(actionToRow(action)) }; case "ade-action": return { type: "ade-action", + ...rowRuntimeOptions(actionToRow(action)), adeAction: action.adeAction ?? { domain: "", action: "" }, }; case "agent-session": return { type: "agent-session", + ...rowRuntimeOptions(actionToRow(action)), ...(action.modelConfig ? { modelConfig: action.modelConfig } : {}), ...(action.permissionConfig ? { permissionConfig: action.permissionConfig } : {}), ...(action.prompt ? { prompt: action.prompt } : {}), @@ -445,6 +570,7 @@ function automationActionToDraftAction( case "launch-mission": return { type: "launch-mission", + ...rowRuntimeOptions(actionToRow(action)), ...(action.sessionTitle ? { missionTitle: action.sessionTitle } : {}), }; case "lane-setup": @@ -454,6 +580,10 @@ function automationActionToDraftAction( } } +function actionToRow(action: AutomationAction): ActionRowValue { + return { kind: action.type === "lane-setup" ? "predict-conflicts" : action.type, ...actionRuntimeOptions(action) } as ActionRowValue; +} + // --- component --- export function RuleEditorPanel({ @@ -496,6 +626,8 @@ export function RuleEditorPanel({ const actionRows = useMemo(() => draftToActionRows(draft), [draft]); const includeProjectContext = computeIncludeProjectContext(draft); const modelValue = draft.modelConfig?.orchestratorModel ?? { modelId: DEFAULT_MODEL_ID, thinkingLevel: "medium" as const }; + const outputs = draft.outputs ?? { disposition: "comment-only" as const, createArtifact: true }; + const verification = draft.verification ?? { verifyBeforePublish: false, mode: "intervention" as const }; const permissionMeta = permissionControlsForModel(modelValue.modelId); const currentPermission = permissionMeta ? draft.permissionConfig?.providers?.[permissionMeta.key] ?? "" @@ -503,7 +635,7 @@ export function RuleEditorPanel({ // laneMode resolution: missing → "reuse" (server-side migration handles // legacy create-lane-as-first-action collapse). - const laneMode: AutomationLaneMode = draft.execution?.laneMode ?? "reuse"; + const laneMode = readLaneMode(draft); const lanePreset: AutomationLaneNamePreset = draft.execution?.laneNamePreset ?? "issue-title"; const laneCustomTemplate = draft.execution?.laneNameTemplate ?? ""; const laneTargetLaneId = draft.execution?.targetLaneId ?? null; @@ -530,7 +662,7 @@ export function RuleEditorPanel({ const patchExecution = ( patch: Partial<{ - laneMode: AutomationLaneMode; + laneMode: DraftLaneMode; targetLaneId: string | null; laneNamePreset: AutomationLaneNamePreset; laneNameTemplate: string; @@ -538,14 +670,15 @@ export function RuleEditorPanel({ ) => { const current = draft.execution ?? { kind: "agent-session" as const }; const next = { ...current }; - if (patch.laneMode !== undefined) next.laneMode = patch.laneMode; + if (patch.laneMode !== undefined) (next as { laneMode?: string }).laneMode = patch.laneMode; if (patch.laneNamePreset !== undefined) next.laneNamePreset = patch.laneNamePreset; if (patch.laneNameTemplate !== undefined) next.laneNameTemplate = patch.laneNameTemplate; if (patch.targetLaneId !== undefined) { if (patch.targetLaneId == null) delete next.targetLaneId; else next.targetLaneId = patch.targetLaneId; } - setDraft({ ...draft, execution: next }); + const nextDraft = { ...draft, execution: next }; + setDraft(isRequireLaneAtRunTimeMode(next.laneMode) ? stripActionTargetLaneIdsFromDraft(nextDraft) : nextDraft); }; // Smart defaults: when the trigger event changes and the user hasn't yet @@ -640,6 +773,20 @@ export function RuleEditorPanel({ checked={draft.enabled} onChange={(next) => setDraft({ ...draft, enabled: next })} /> + @@ -766,6 +913,55 @@ export function RuleEditorPanel({ }); }} /> +
+ Review profile + +
+
+ Tool palette +
+ {TOOL_FAMILIES.map((tool) => { + const checked = draft.toolPalette.includes(tool.value); + const wouldEmptyPalette = checked && draft.toolPalette.length === 1; + return ( + + ); + })} +
+
Model +
+ + + setDraft({ + ...draft, + guardrails: { + ...draft.guardrails, + maxFindings: n == null ? undefined : Math.max(1, Math.floor(n)), + }, + }) + } + placeholder="Default" + /> +
patchTrigger({ activeHours: next ?? undefined })} @@ -833,6 +1070,86 @@ export function RuleEditorPanel({
+ + {/* Output */} +
+
+ + setDraft({ ...draft, outputs: { ...outputs, createArtifact: next } })} + /> + +
+ + + setDraft({ + ...draft, + verification: { ...verification, verifyBeforePublish: next }, + }) + } + /> +
+
+
{/* Right column — workflow steps */} @@ -850,6 +1167,7 @@ export function RuleEditorPanel({ lanes={lanes} suites={suites} fallbackModel={modelValue} + executionLaneMode={laneMode} onChange={setActionRows} onOpenAiSettings={openAiSettings} /> @@ -869,13 +1187,23 @@ function LaneModeControl({ lanes, onChange, }: { - laneMode: AutomationLaneMode; + laneMode: DraftLaneMode; targetLaneId: string | null; lanes: Array<{ id: string; name: string }>; - onChange: (patch: { laneMode?: AutomationLaneMode; targetLaneId?: string | null }) => void; + onChange: (patch: { laneMode?: DraftLaneMode; targetLaneId?: string | null }) => void; }) { - // Compose a single value: "create", "reuse:" (primary), or "reuse:". - const selectValue = laneMode === "create" ? "create" : `reuse:${targetLaneId ?? ""}`; + const knownMode = laneMode === "create" + || laneMode === "reuse" + || laneMode === "provided" + || laneMode === "prompt-at-run" + || laneMode === "require-on-trigger"; + const selectValue = laneMode === "create" + ? "create" + : laneMode === "provided" || laneMode === "prompt-at-run" || laneMode === "require-on-trigger" + ? "require-on-trigger" + : laneMode === "reuse" + ? `reuse:${targetLaneId ?? ""}` + : `unknown:${laneMode}`; const sortedLanes = useMemo(() => [...lanes].sort((a, b) => a.name.localeCompare(b.name)), [lanes]); return ( @@ -890,6 +1218,10 @@ function LaneModeControl({ onChange({ laneMode: "create", targetLaneId: null }); return; } + if (v === "require-on-trigger") { + onChange({ laneMode: "require-on-trigger", targetLaneId: null }); + return; + } if (v === "reuse:") { onChange({ laneMode: "reuse", targetLaneId: null }); return; @@ -900,11 +1232,17 @@ function LaneModeControl({ }} > + {sortedLanes.map((lane) => ( ))} + {!knownMode ? ( + + ) : null} ); @@ -1366,4 +1704,3 @@ function ActiveHoursFields({ ); } - diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 8aa13e3d3..28e38bfe7 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -626,6 +626,7 @@ export function AgentChatComposer({ hideNativeControls = false, messagePlaceholder, onModelChange, + onModelCatalogOpen, onReasoningEffortChange, onCodexFastModeChange, onDraftChange, @@ -735,6 +736,7 @@ export function AgentChatComposer({ hideNativeControls?: boolean; messagePlaceholder?: string; onModelChange: (modelId: string) => void; + onModelCatalogOpen?: () => void; onReasoningEffortChange: (reasoningEffort: string | null) => void; onCodexFastModeChange?: (enabled: boolean) => void; onDraftChange: (value: string) => void; @@ -2870,6 +2872,7 @@ export function AgentChatComposer({ onParallelSlotModelChange?.(parallelConfiguringIndex, next)} + onOpen={onModelCatalogOpen} availableModelIds={availableModelIds} disabled={parallelLaunchBusy} showReasoning @@ -2890,6 +2893,7 @@ export function AgentChatComposer({ { }); }); - it("does not block chat boot on Cursor model inventory after AI status resolves", async () => { + it("does not auto-fetch Cursor inventory on chat boot", async () => { let resolveProjectConfig: (value: unknown) => void = () => {}; const projectConfig = new Promise((resolve) => { resolveProjectConfig = resolve; }); - const cursorModels = new Promise(() => {}); installAdeMocks({ sessions: [], }); @@ -1092,10 +1091,7 @@ describe("AgentChatPane submit recovery", () => { ], availableModelIds: [], }) as any; - window.ade.agentChat.models = vi.fn().mockImplementation(({ provider }: { provider: string }) => { - if (provider === "cursor") return cursorModels; - return Promise.resolve([]); - }) as any; + window.ade.agentChat.models = vi.fn().mockResolvedValue([]) as any; render( @@ -1121,10 +1117,10 @@ describe("AgentChatPane submit recovery", () => { expect(screen.queryByText("Loading sessions")).toBeNull(); }); expect(await screen.findByText("Start a new conversation")).toBeTruthy(); - expect(window.ade.agentChat.models).toHaveBeenCalledWith({ - provider: "cursor", - activateRuntime: true, - }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(window.ade.agentChat.models).not.toHaveBeenCalledWith( + expect.objectContaining({ provider: "cursor" }), + ); }); it("uses Cursor model IDs from AI status without probing Cursor inventory", async () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index c935a3102..c5fcd240a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1505,6 +1505,25 @@ function chatSessionTitle(session: AgentChatSessionSummary): string { return descriptor?.displayName ?? `${session.provider}/${session.model}`; } +function orderAvailableModelIds(ids: Iterable): string[] { + const available = new Set(ids); + const ordered = MODEL_REGISTRY + .filter((model) => !model.deprecated && available.has(model.id)) + .map((model) => model.id); + const extra = [...available].filter((modelId) => !ordered.includes(modelId)); + extra.sort((left, right) => { + const leftLabel = getModelById(left)?.displayName ?? left; + const rightLabel = getModelById(right)?.displayName ?? right; + return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: "base" }); + }); + return [...ordered, ...extra]; +} + +function isCursorModelId(id: string): boolean { + return id.startsWith("cursor/") + || getModelById(id)?.family === "cursor"; +} + function completionBadgeClass(status: NonNullable["status"]): string { switch (status) { case "completed": return "border-emerald-400/20 bg-emerald-400/[0.08] text-emerald-300"; @@ -2694,25 +2713,7 @@ export function AgentChatPane({ }); const refreshAvailableModels = useCallback(async () => { - const refreshSeq = ++availableModelsRefreshSeqRef.current; - const orderModelIds = (ids: Iterable): string[] => { - const available = new Set(ids); - const ordered = MODEL_REGISTRY - .filter((model) => !model.deprecated && available.has(model.id)) - .map((model) => model.id); - const extra = [...available].filter((modelId) => !ordered.includes(modelId)); - extra.sort((left, right) => { - const leftLabel = getModelById(left)?.displayName ?? left; - const rightLabel = getModelById(right)?.displayName ?? right; - return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: "base" }); - }); - return [...ordered, ...extra]; - }; - const isCursorModelId = (id: string): boolean => ( - id.startsWith("cursor/") - || getModelById(id)?.family === "cursor" - ); - + ++availableModelsRefreshSeqRef.current; const selectedModelProvider = modelId.trim() ? resolveChatRuntimeProvider(getModelById(modelId)) : null; @@ -2735,34 +2736,8 @@ export function AgentChatPane({ droid: status.providerConnections?.droid ?? null, }); const available = deriveConfiguredModelIds(status, { includeDroid: true }); - const orderedAvailable = orderModelIds(available); + const orderedAvailable = orderAvailableModelIds(available); setAvailableModelIds(orderedAvailable); - const cursorReady = status.availableProviders?.cursor === true - || status.providerConnections?.cursor?.runtimeAvailable === true; - const hasCursorModelIds = orderedAvailable.some(isCursorModelId); - if (!cursorReady || hasCursorModelIds) return orderedAvailable; - - void getAgentChatModelsCached({ - projectRoot, - provider: "cursor", - activateRuntime: true, - }).then((cursorModels) => { - if (availableModelsRefreshSeqRef.current !== refreshSeq) return; - if (!cursorModels.length) { - const withoutCursor = orderedAvailable.filter((id) => !isCursorModelId(id)); - setAvailableModelIds(withoutCursor); - return; - } - - const merged = new Set(available); - for (const model of cursorModels) { - const resolved = resolveCliRegistryModelId("cursor", model.id); - if (resolved) merged.add(resolved); - } - const withCursor = orderModelIds(merged); - setAvailableModelIds(withCursor); - }).catch(() => undefined); - return orderedAvailable; } catch { setAiStatus(null); @@ -2809,7 +2784,7 @@ export function AgentChatPane({ } } - const allAvailable = orderModelIds(available); + const allAvailable = orderAvailableModelIds(available); setAvailableModelIds(allAvailable); return allAvailable; } catch { @@ -2818,6 +2793,38 @@ export function AgentChatPane({ } }, [modelId, projectRoot, selectedSession?.provider, sessionProvider]); + const refreshCursorModelInventory = useCallback(async () => { + const status = aiStatus; + const cursorReady = status?.availableProviders?.cursor === true + || status?.providerConnections?.cursor?.runtimeAvailable === true; + if (!cursorReady) return; + if (availableModelIds.some(isCursorModelId)) return; + const refreshSeq = availableModelsRefreshSeqRef.current; + let cursorModels: Awaited>; + try { + cursorModels = await getAgentChatModelsCached({ + projectRoot, + provider: "cursor", + activateRuntime: true, + }); + } catch { + return; + } + if (availableModelsRefreshSeqRef.current !== refreshSeq) return; + if (!cursorModels.length) { + setAvailableModelIds((prev) => prev.filter((id) => !isCursorModelId(id))); + return; + } + setAvailableModelIds((prev) => { + const merged = new Set(prev); + for (const model of cursorModels) { + const resolved = resolveCliRegistryModelId("cursor", model.id); + if (resolved) merged.add(resolved); + } + return orderAvailableModelIds(merged); + }); + }, [aiStatus, availableModelIds, projectRoot]); + const touchSession = useCallback((sessionId: string | null | undefined, touchedAt = new Date().toISOString()) => { if (!sessionId) return; const previousTouch = localTouchBySessionRef.current.get(sessionId); @@ -5271,6 +5278,7 @@ export function AgentChatPane({ { void updateNativeControls({ interactionMode: value }); }} onClaudeModeChange={handleClaudeModeChange} onClaudePermissionModeChange={(value) => { void updateNativeControls({ claudePermissionMode: value }); }} diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index e026c29ec..4a1cc299c 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -675,10 +675,9 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { const applyLocalPrState = useCallback(async () => { const shouldLoadWorkflowState = activeTabRef.current !== "normal"; const shouldLoadRebaseState = shouldLoadWorkflowState || selectedPrIdRef.current !== null; - const shouldLoadDecoratedLaneStatus = shouldLoadWorkflowState || selectedPrIdRef.current !== null; const [prList, laneList, queueStateList, refreshedRebaseNeeds, refreshedAutoRebaseStatuses] = await Promise.all([ window.ade.prs.listWithConflicts(), - window.ade.lanes.list({ includeStatus: shouldLoadDecoratedLaneStatus }), + window.ade.lanes.list({ includeStatus: shouldLoadRebaseState }), shouldLoadWorkflowState ? window.ade.prs.listQueueStates({ includeCompleted: true, limit: 50 }) : Promise.resolve([] as QueueLandingState[]), diff --git a/apps/desktop/src/renderer/components/shared/ProviderModelSelector.tsx b/apps/desktop/src/renderer/components/shared/ProviderModelSelector.tsx index e3ef2181a..f3ba75553 100644 --- a/apps/desktop/src/renderer/components/shared/ProviderModelSelector.tsx +++ b/apps/desktop/src/renderer/components/shared/ProviderModelSelector.tsx @@ -12,6 +12,7 @@ import { SmartTooltip } from "../ui/SmartTooltip"; type ProviderModelSelectorProps = { value: string; onChange: (modelId: string) => void; + onOpen?: () => void; filter?: (model: ModelDescriptor) => boolean; availableModelIds?: string[]; catalogMode?: "all" | "available-only"; @@ -48,6 +49,7 @@ function tierLabel(tier: string): string { export function ProviderModelSelector({ value, onChange, + onOpen, filter, availableModelIds, catalogMode = "all", @@ -210,6 +212,7 @@ export function ProviderModelSelector({ disabled={disabled} onClick={() => { if (disabled) return; + if (!open) onOpen?.(); setOpen((current) => !current); }} className={cn( diff --git a/apps/desktop/src/shared/types/automations.ts b/apps/desktop/src/shared/types/automations.ts index 10694eae2..1e441da0d 100644 --- a/apps/desktop/src/shared/types/automations.ts +++ b/apps/desktop/src/shared/types/automations.ts @@ -215,6 +215,7 @@ export type AutomationPlannerConfig = export type AutomationDraftActionBase = { type: AutomationActionType; + targetLaneId?: string | null; condition?: string; continueOnFailure?: boolean; timeoutMs?: number; diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 4562db567..69eadad12 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -746,7 +746,7 @@ export type AutomationAction = { export type AutomationExecutionKind = "agent-session" | "mission" | "built-in"; -export type AutomationLaneMode = "create" | "reuse"; +export type AutomationLaneMode = "create" | "reuse" | "require-on-trigger"; export type AutomationLaneNamePreset = | "issue-title" @@ -758,7 +758,8 @@ export type AutomationExecution = { kind: AutomationExecutionKind; /** * Whether each run should spawn a fresh lane (`"create"`) or reuse the - * configured / trigger / primary lane (`"reuse"`). Defaults to `"reuse"`. + * configured / trigger / primary lane (`"reuse"`), or require the trigger + * caller/event to supply a lane (`"require-on-trigger"`). Defaults to `"reuse"`. */ laneMode?: AutomationLaneMode; /** @@ -837,8 +838,6 @@ export type AutomationOutputs = { notificationChannel?: string | null; }; -/** @deprecated Review/verification gate is no longer surfaced in the UI; kept - * for YAML compatibility so existing rules still load. */ export type AutomationVerification = { verifyBeforePublish: boolean; mode?: "intervention" | "dry-run"; @@ -858,9 +857,7 @@ export type AutomationRule = { permissionConfig?: MissionPermissionConfig; templateId?: string; prompt?: string; - /** @deprecated Review profile is no longer surfaced in the UI. */ reviewProfile: AutomationReviewProfile; - /** @deprecated Tool palette is no longer surfaced in the UI. */ toolPalette: AutomationToolFamily[]; /** @deprecated Replaced by `includeProjectContext` in the UI. */ contextSources: AutomationContextSource[]; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8df62800f..e33f59770 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -571,10 +571,14 @@ Enforced rules (from the stability overhaul): 2. New integrations are dormant-until-configured. 3. Feature pages stage data: cheapest (list/summary/topology) first, heavy (dashboard/settings/model metadata/overlays) on delay. 4. Never mount expensive trees eagerly — settings dialogs, advanced launcher sections unmount when closed. -5. Renderer polling is route-scoped; terminal attention only polls on terminal routes; lane panels only poll while live sessions exist. +5. Renderer polling is route-scoped; terminal attention only polls on terminal routes; lane panels only poll while live sessions exist. The plain PR list does not fire a GitHub refresh on mount and skips rebase-needs / auto-rebase polling until the user opens a workflow tab or selects a PR. The Lanes page reuses the `LaneSummary.autoRebaseStatus` snapshot already in the lane list instead of probing per-lane on `LaneGitActionsPane` mount; a fallback probe runs only when the snapshot is missing and after a visibility-gated 3.5 s delay. The Work top-bar sync chip refreshes on focus and on `sync-status` events instead of a 5 s interval. The chat composer's Cursor model inventory is fetched lazily — `ProviderModelSelector` calls `onOpen` on first open of the model catalog, and `AgentChatPane.refreshCursorModelInventory` is the only entry point that hits `cursor` with `activateRuntime: true`. 6. Shared caches for high-frequency calls (`sessionListCache`, GitHub fingerprint-based snapshots). 7. Memoize expensive renderer computations (`useMemo`, `React.memo`); isolate frequently-refreshing subtrees (e.g., budget footers). 8. `Promise.allSettled` over `Promise.all` for parallel startup — one failing service must not block others. +9. Settings sections that surface a snapshot read the cached snapshot on mount (`ade.usage.getSnapshot`) instead of forcing a refresh; an explicit Refresh button drives recompute. +10. Persistence callbacks dedupe against the last-saved value: the workspace-graph view-mode persister tracks the last-loaded preference root and skips the immediate write that the load handler's `setViewMode` would otherwise fire. + +CLI-launcher and shell-quoting helpers (`cliLaunch.ts`, `shell.ts`) live under `apps/desktop/src/renderer/` only — the prior `apps/desktop/src/shared/` copies were renderer-only in practice and have been removed. The mobile-launcher path (`work.startCliSession`) was retired with them; iOS launches CLI sessions through host-side actions that don't share renderer modules. Themes: six shipped themes (`e-paper`, `bloomberg`, `github`, `rainbow`, `sky`, `pats`), persisted in `localStorage.ade.theme`, applied via `data-theme` on root. Token-based palettes in `apps/desktop/src/renderer/index.css`. diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index b392aa685..3e4b9d1de 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -98,7 +98,14 @@ and a footer that contains the composer. - **Model selection.** `ProviderModelSelector` is embedded and filters the registry via `filterChatModelIdsForSession`. Switching within the allowed family is a normal update; crossing families triggers a - handoff. + handoff. The Cursor model inventory (`getAgentChatModelsCached` with + `provider: "cursor"`, `activateRuntime: true`) is no longer fetched + on chat boot — the selector exposes an `onOpen` callback that fires + the first time the user actually opens the model catalog, and + `AgentChatPane.refreshCursorModelInventory` is the only path that + performs the active probe. It also no-ops when the latest + `availableModelIds` already contains a Cursor entry, so re-opening + the catalog after a successful inventory does not refire the probe. - **Reasoning effort.** Dropdown for models that support reasoning tiers. - **Fast mode (Codex).** A yellow Lightning chip next to the model diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 598d8ef67..66860f30a 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -40,7 +40,7 @@ Renderer components: | `renderer/components/lanes/LaneContextMenu.tsx` | Right-click menu on the lane list. Hosts the inline color swatch row that calls `lanes.updateAppearance` directly, "Reveal/Copy path", manage/adopt/open-in-Run actions, split-tab actions, and batch manage. | | `renderer/components/lanes/LaneStackPane.tsx` | Stack graph sidebar, integration source chips, canvas jump | | `renderer/components/lanes/LaneDiffPane.tsx` | Diff viewer, per-file stage/unstage/discard | -| `renderer/components/lanes/LaneGitActionsPane.tsx` | Commit, stash, fetch, sync, push, recent commits | +| `renderer/components/lanes/LaneGitActionsPane.tsx` | Commit, stash, fetch, sync, push, recent commits. Seeds its `autoRebaseStatus` from the `autoRebaseStatusSnapshot` prop that `LanesPage` passes from the lane list (`laneSnapshot.autoRebaseStatus`), so opening a lane does not trigger a per-lane probe. A fallback `refreshAutoRebaseStatus` runs only when the snapshot is `undefined`, after a 3.5 s delay, and only while the document is visible. | | `renderer/components/lanes/LaneWorkPane.tsx` | Terminal/chat toggle work surface | | `renderer/components/lanes/LaneRebaseBanner.tsx` | Inline banner driven by `rebaseSuggestionService` | | `renderer/components/lanes/LaneEnvInitProgress.tsx` | Env init step progress inside create dialog | diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index a2984b342..ccadb3a24 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -181,7 +181,11 @@ Renderer — settings: via `tailscale serve`), and the per-device connection panel used to forget paired phones. - `apps/desktop/src/renderer/components/settings/SettingsUsageSection.tsx` - and `UsageGuardrailsSection.tsx` — cost and usage. + and `UsageGuardrailsSection.tsx` — cost and usage. The guardrails + section's mount-time hydrate calls `ade.usage.getSnapshot` (cached + read), not `ade.usage.refresh` (which forces a recompute); the user + still gets the live numbers via the section's explicit Refresh + control. - `apps/desktop/src/renderer/components/settings/ProxyAndPreviewSection.tsx` — proxy/preview configuration UI. - `apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.tsx` diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index 853f53e57..73f44e7ff 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -531,6 +531,7 @@ best-effort — failures log a warning and do not abort the tick. - `PRsPage` parses URL state via `parsePrsRouteState` and writes it back with `buildPrsRouteSearch`. Active tab, workflow sub-tab, selected PR, queue group, lane, and rebase item are all encoded. +- `PrsContext` mounts cheaply on the plain GitHub PR list. The initial `refreshCore` only kicks a background GitHub refresh when the active tab is a workflow tab (`queue` / `integration` / `rebase`) or a PR is selected; otherwise `githubRefreshMode` is left undefined so the renderer paints from the existing snapshot. `applyLocalPrState` calls `lanes.list({ includeStatus: false })` and skips `rebase.scanNeeds` / `lanes.listAutoRebaseStatuses` on the plain list — those legs hydrate the moment a workflow tab opens or a PR is selected, and the periodic 60 s rebase scan + auto-rebase listener also no-op while the user is on the plain list. - `PrsContext` owns PR list, queue states, rebase needs, proposals, convergence runtime state, and the Timeline+Rails UI state (`prsTimelineRailsEnabled`, `timelineFiltersByPrId`, diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 266456563..519663824 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -258,10 +258,13 @@ Renderer surfaces: iOS Work surfaces: -- `apps/ios/ADE/Views/Work/WorkRootScreen.swift` and - `WorkRootScreen+Actions.swift` — mobile Work list, filters, - grouped session rows, live-count/status pills, and the resume - flow that re-uses `work.startCliSession` for ended PTY rows. +- `apps/ios/ADE/Views/Work/WorkRootScreen.swift`, + `WorkRootScreen+Actions.swift`, `WorkRootScreen+Selection.swift`, and + `WorkRootComponents.swift` — mobile Work list, filters, grouped + session rows, and live-count/status pills, plus the resume flow that + re-uses `work.startCliSession` for ended PTY rows. The earlier + in-list activity feed is gone — running chats surface through the + session list and the live-count chip. - `apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift` — terminal artifact/output views and the compact input bar that sends `terminal_input` bytes and Ctrl-C to the subscribed host PTY. Hosts diff --git a/docs/features/workspace-graph/README.md b/docs/features/workspace-graph/README.md index 91881a6f3..f98fa6d26 100644 --- a/docs/features/workspace-graph/README.md +++ b/docs/features/workspace-graph/README.md @@ -105,6 +105,13 @@ returns to their preferred view across sessions. `normalizeGraphPreferences(state)` migrates legacy schemas (including the older `presets: […]` shape) to the current format. +The persistence callback dedupes against the value just loaded: +`GraphInner` keeps a `skipNextGraphPreferencePersistRootRef` set to the +project root that was just hydrated. The next `viewMode`-watcher run +skips its `graphState.set` because the load handler's `setViewMode` +would otherwise echo the loaded preference straight back to disk on +every project switch. + ## Node data (`GraphNodeData`) Every lane node carries enough derived state to render without From b7da2de8a4d8f47e7990dbee42ac2845eeae411c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 12:09:27 -0400 Subject: [PATCH 11/14] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20addre?= =?UTF-8?q?ss=20greptile=20+=20coderabbit=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PrsContext: cleanup symmetry on rebase-scan effect early-return - AgentChatPane: don't bail Cursor probe when aiStatus is null - AppShell: clear welcome state on new-tab early-return path - automationService: honor configured target lane in predict-conflicts - RuleEditorPanel: default toolPalette before checkbox grid render - RulesTab: guard manual trigger against duplicate submissions - ade-cli: --lane optional in trigger help; effective laneMode validation - automations: extract parseList to shared module - UsageGuardrailsSection.test: wait on enabled button instead of getSnapshot --- apps/ade-cli/src/cli.ts | 14 +++++++++----- .../main/services/automations/automationService.ts | 2 +- .../src/renderer/components/app/AppShell.tsx | 1 + .../automations/GitHubTriggerFilters.tsx | 6 +----- .../automations/LinearTriggerFilters.tsx | 6 +----- .../renderer/components/automations/RulesTab.tsx | 11 ++++++++--- .../automations/components/RuleEditorPanel.tsx | 9 +++++---- .../src/renderer/components/automations/shared.ts | 5 +++++ .../src/renderer/components/chat/AgentChatPane.tsx | 8 +++++--- .../renderer/components/prs/state/PrsContext.tsx | 6 +++++- .../settings/UsageGuardrailsSection.test.tsx | 3 ++- 11 files changed, 43 insertions(+), 28 deletions(-) diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 2e5aec050..f7b1328c2 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1210,7 +1210,7 @@ const HELP_BY_COMMAND: Record = { $ ade automations delete Remove a local rule $ ade automations toggle --enabled true|false $ ade automations run [--lane ] [--dry-run] - $ ade automations trigger --lane + $ ade automations trigger [--lane ] Trigger a rule manually $ ade automations runs [--rule ] [--status ] [--limit 50] $ ade automations run-show [--json] Inspect a run @@ -3739,20 +3739,24 @@ function applyLaneFlagsToDraft(draft: JsonObject, args: string[]): JsonObject { return draft; } - if (laneId != null && laneMode != null && laneMode !== "reuse") { + const existingExecution = isRecord(draft.execution) ? draft.execution : {}; + const effectiveLaneMode = + laneMode + ?? (asString(existingExecution.laneMode) as AutomationLaneModeFlag | null); + + if (laneId != null && effectiveLaneMode !== "reuse") { throw new CliUsageError("--lane is only valid with --lane-mode reuse."); } - if (preset != null && laneMode != null && laneMode !== "create") { + if (preset != null && effectiveLaneMode !== "create") { throw new CliUsageError("--lane-name-preset is only valid with --lane-mode create."); } if (template != null && preset != null && preset !== "custom") { throw new CliUsageError("--lane-name-template is only valid with --lane-name-preset custom."); } - if (template != null && preset == null && laneMode !== "create") { + if (template != null && preset == null && effectiveLaneMode !== "create") { throw new CliUsageError("--lane-name-template requires --lane-mode create (with --lane-name-preset custom)."); } - const existingExecution = isRecord(draft.execution) ? draft.execution : {}; const execution: JsonObject = { ...existingExecution }; if (laneMode != null) execution.laneMode = laneMode; if (laneId != null) execution.targetLaneId = laneId; diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 4a2510f66..1d7d72e31 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -1797,7 +1797,7 @@ export function createAutomationService({ if (!conflictService) throw new Error("Conflict service unavailable"); const laneId = requiresTriggerLane(rule) || rule.execution?.laneMode === "create" ? await resolveExecutionLaneId(rule, trigger, action, runId) - : trigger.laneId; + : getConfiguredTargetLaneId(rule, action) ?? trigger.laneId; await conflictService.runPrediction(laneId ? { laneId } : {}); return { status: "succeeded" }; } diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 160ade8ea..7825b89c1 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -377,6 +377,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { if (currentIsNewTabOpen && nextProject && !projectChanged) { setProject(nextProject); + if (currentShowWelcome) setShowWelcome(false); return; } diff --git a/apps/desktop/src/renderer/components/automations/GitHubTriggerFilters.tsx b/apps/desktop/src/renderer/components/automations/GitHubTriggerFilters.tsx index 9df471fb5..10efbd10d 100644 --- a/apps/desktop/src/renderer/components/automations/GitHubTriggerFilters.tsx +++ b/apps/desktop/src/renderer/components/automations/GitHubTriggerFilters.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import type { AutomationTrigger } from "../../../shared/types"; import { cn } from "../ui/cn"; -import { INPUT_CLS, INPUT_STYLE } from "./shared"; +import { INPUT_CLS, INPUT_STYLE, parseList } from "./shared"; type GitHubApi = { listRepoLabels?: (args: { owner: string; name: string }) => Promise>; @@ -21,10 +21,6 @@ function parseRepoSlug(value: string | null | undefined): { owner: string; name: return match ? { owner: match[1]!, name: match[2]! } : null; } -function parseList(value: string): string[] { - return value.split(",").map((entry) => entry.trim()).filter(Boolean); -} - export function GitHubTriggerFilters({ trigger, onPatch, diff --git a/apps/desktop/src/renderer/components/automations/LinearTriggerFilters.tsx b/apps/desktop/src/renderer/components/automations/LinearTriggerFilters.tsx index b5477e0cf..ca538c44e 100644 --- a/apps/desktop/src/renderer/components/automations/LinearTriggerFilters.tsx +++ b/apps/desktop/src/renderer/components/automations/LinearTriggerFilters.tsx @@ -1,9 +1,5 @@ import type { AutomationTrigger } from "../../../shared/types"; -import { INPUT_CLS, INPUT_STYLE } from "./shared"; - -function parseList(value: string): string[] { - return value.split(",").map((entry) => entry.trim()).filter(Boolean); -} +import { INPUT_CLS, INPUT_STYLE, parseList } from "./shared"; export function LinearTriggerFilters({ trigger, diff --git a/apps/desktop/src/renderer/components/automations/RulesTab.tsx b/apps/desktop/src/renderer/components/automations/RulesTab.tsx index 57e17553f..5d49c76ea 100644 --- a/apps/desktop/src/renderer/components/automations/RulesTab.tsx +++ b/apps/desktop/src/renderer/components/automations/RulesTab.tsx @@ -335,6 +335,7 @@ export function RulesTab({ const [error, setError] = useState(null); const [manualRunRule, setManualRunRule] = useState(null); const [manualRunLaneId, setManualRunLaneId] = useState(""); + const [manualRunPending, setManualRunPending] = useState(false); const [configTrustRequired, setConfigTrustRequired] = useState(false); const loadRef = useRef<(() => Promise) | null>(null); // Snapshot of the last-saved (or last-loaded-from-rule) draft, used to detect @@ -514,16 +515,20 @@ export function RulesTab({ }; const runRuleNow = useCallback(async (ruleId: string, laneId?: string | null) => { + if (manualRunPending) return; + setManualRunPending(true); setError(null); try { await window.ade.automations.triggerManually({ id: ruleId, ...(laneId ? { laneId } : {}) }); - await refresh(); setManualRunRule(null); setManualRunLaneId(""); + await refresh(); } catch (err) { setError(extractError(err)); + } finally { + setManualRunPending(false); } - }, [refresh]); + }, [manualRunPending, refresh]); const beginRunRule = useCallback((rule: AutomationRuleSummary) => { if (rule.execution?.laneMode === "require-on-trigger") { @@ -759,7 +764,7 @@ export function RulesTab({