From 96ddefd8a80d709b8ed408b4d0173a06cb779f4b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 05:32:18 -0700 Subject: [PATCH 1/2] Add tests for context-menu Run now invalidating routine queries Covers the run-now success case (onRoutineRan called with the tenant id) and failure case (not called) so the fix below has a red/green pair to prove against. --- .../web/src/shell/context-menu/items.test.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/apps/web/src/shell/context-menu/items.test.tsx b/apps/web/src/shell/context-menu/items.test.tsx index df1ca0cb2..7f1997d6e 100644 --- a/apps/web/src/shell/context-menu/items.test.tsx +++ b/apps/web/src/shell/context-menu/items.test.tsx @@ -39,6 +39,7 @@ function actions( openProfile: mock(() => undefined), cycleTheme: mock(() => undefined), signOut: mock(() => undefined), + onRoutineRan: mock((_tenantId: string) => undefined), ...overrides, }; } @@ -183,6 +184,54 @@ describe("shellContextMenuFor: routine", () => { findItem(menu.entries, "open").onSelect(); expect(navigate).toHaveBeenCalledWith("/routines/rt-1"); }); + + test("run-now invalidates routine queries via onRoutineRan once the run starts", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ runId: "run-1" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ) as unknown as typeof fetch; + const onRoutineRan = mock((_tenantId: string) => undefined); + try { + const menu = shellContextMenuFor(target, actions({ onRoutineRan })); + findItem(menu.entries, "run-now").onSelect(); + for (let i = 0; i < 10 && onRoutineRan.mock.calls.length === 0; i++) { + await Promise.resolve(); + } + expect(onRoutineRan).toHaveBeenCalledWith("tenant-1"); + expect(toastMock).toHaveBeenCalledWith("Nightly Digest started"); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("run-now does not invalidate routine queries when the run fails to start", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ error: { message: "boom" } }), { + status: 500, + headers: { "content-type": "application/json" }, + }), + ), + ) as unknown as typeof fetch; + const onRoutineRan = mock((_tenantId: string) => undefined); + try { + const menu = shellContextMenuFor(target, actions({ onRoutineRan })); + findItem(menu.entries, "run-now").onSelect(); + for (let i = 0; i < 10 && toastMock.mock.calls.length === 0; i++) { + await Promise.resolve(); + } + expect(toastMock).toHaveBeenCalledWith("Couldn't start the routine"); + expect(onRoutineRan).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = realFetch; + } + }); }); describe("shellContextMenuFor: insights-run", () => { From 588d686ea2babcd9df001d9dca4fb6414c364d95 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 05:32:34 -0700 Subject: [PATCH 2/2] Refresh routines list and run history after context-menu Run now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a routine and choosing Run now only toasted — the routines list and run history stayed stale until an unrelated refetch, unlike the routine panel's own Run now button. Thread onRoutineRan through ShellContextMenuActions and reuse the panel's exact invalidation via a shared invalidateRoutineQueries helper. Fixes CL-7137. --- apps/web/src/query-client.ts | 18 ++++++++++++++++++ apps/web/src/shell/context-menu/items.tsx | 9 ++++++++- .../shell/context-menu/shell-context-menu.tsx | 13 ++++++++++++- apps/web/src/shell/routine-panel.tsx | 9 ++------- 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts index e7bc333b9..5b0fbb2f6 100644 --- a/apps/web/src/query-client.ts +++ b/apps/web/src/query-client.ts @@ -160,6 +160,24 @@ export const tenantKeys = { ] as const, }; +/** + * Every surface that runs a routine (the routine panel's Run now button, + * the shell context menu's Run now item) must refresh the same two reads — + * the routines list and its run history — or one of them goes stale while + * the other doesn't. + */ +export function invalidateRoutineQueries( + queryClient: QueryClient, + tenantId: string, +): void { + void queryClient.invalidateQueries({ + queryKey: tenantKeys.routines(tenantId), + }); + void queryClient.invalidateQueries({ + queryKey: tenantKeys.routineRunHistories(tenantId), + }); +} + /** * Map a hub GET path onto a stable query key. Unknown paths fall back to a * path-keyed entry so callers cannot accidentally share cache entries. diff --git a/apps/web/src/shell/context-menu/items.tsx b/apps/web/src/shell/context-menu/items.tsx index 8d4a6ac3d..52f6d1d20 100644 --- a/apps/web/src/shell/context-menu/items.tsx +++ b/apps/web/src/shell/context-menu/items.tsx @@ -45,6 +45,10 @@ export type ShellContextMenuActions = { readonly openProfile: (subject: ProfileSubject) => void; readonly cycleTheme: () => void; readonly signOut: () => void; + /** Called with the tenant id after a routine's Run now succeeds, so the + * caller can refresh the routines list and run history that a stale + * right-click menu has no other way to know changed. */ + readonly onRoutineRan: (tenantId: string) => void; }; async function copyLink(path: string, label: string): Promise { @@ -152,7 +156,10 @@ function routineMenu( icon: , onSelect: () => { void runRoutineNow(tenantId, target.id).then( - () => toast(`${target.name} started`), + () => { + toast(`${target.name} started`); + actions.onRoutineRan(tenantId); + }, () => toast("Couldn't start the routine"), ); }, diff --git a/apps/web/src/shell/context-menu/shell-context-menu.tsx b/apps/web/src/shell/context-menu/shell-context-menu.tsx index 7f3222743..5465cf1f7 100644 --- a/apps/web/src/shell/context-menu/shell-context-menu.tsx +++ b/apps/web/src/shell/context-menu/shell-context-menu.tsx @@ -9,10 +9,12 @@ import { useContextMenuState, useDocumentContextMenuTrigger, } from "@corbits/context-menu"; +import { useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; import { useBench } from "../../bench-context"; import { useNavigate } from "../../navigation"; +import { invalidateRoutineQueries } from "../../query-client"; import { useOpenProfileInCanvas } from "../canvas-availability"; import { shellContextMenuFor } from "./items"; import type { ShellContextMenuActions } from "./items"; @@ -30,6 +32,7 @@ export function ShellContextMenu({ const navigate = useNavigate(); const openProfile = useOpenProfileInCanvas(); const { cycleMode } = useTheme(); + const queryClient = useQueryClient(); const { open, x, y, menu, triggerElement, show, hide } = useContextMenuState(); @@ -39,6 +42,7 @@ export function ShellContextMenu({ openProfile, cycleTheme: cycleMode, signOut: onSignOut, + onRoutineRan: (tenantId) => invalidateRoutineQueries(queryClient, tenantId), }; const resolve = useCallback( @@ -52,7 +56,14 @@ export function ShellContextMenu({ }, // `actions` is a fresh object every render; the values it closes over // are what actually determine the menu, so those are the real deps. - [selectedTenantId, navigate, openProfile, cycleMode, onSignOut], + [ + selectedTenantId, + navigate, + openProfile, + cycleMode, + onSignOut, + queryClient, + ], ); useDocumentContextMenuTrigger({ resolve, onOpen: show }); diff --git a/apps/web/src/shell/routine-panel.tsx b/apps/web/src/shell/routine-panel.tsx index 34b010527..771d4c077 100644 --- a/apps/web/src/shell/routine-panel.tsx +++ b/apps/web/src/shell/routine-panel.tsx @@ -76,7 +76,7 @@ import { slackTriggerOffered, } from "../deployment-capabilities-api"; import { useGranolaPluginConnected } from "../granola-plugin-availability"; -import { tenantKeys } from "../query-client"; +import { invalidateRoutineQueries } from "../query-client"; import { useCanvasColumnRoutine, useCloseCanvas } from "./canvas-availability"; import type { RoutinePanelSubject } from "./canvas-availability"; import { CanvasPaneHeader } from "./canvas-column"; @@ -202,12 +202,7 @@ function RoutineEditorPanel({ const invalidateRoutines = () => { if (tenantId === null) return; - void queryClient.invalidateQueries({ - queryKey: tenantKeys.routines(tenantId), - }); - void queryClient.invalidateQueries({ - queryKey: tenantKeys.routineRunHistories(tenantId), - }); + invalidateRoutineQueries(queryClient, tenantId); }; const [routineId, setRoutineId] = useState(