diff --git a/packages/shared/src/validators/task-workspace.ts b/packages/shared/src/validators/task-workspace.ts index 3237763bc36..b6b5813c6f5 100644 --- a/packages/shared/src/validators/task-workspace.ts +++ b/packages/shared/src/validators/task-workspace.ts @@ -9,6 +9,15 @@ export const executionWorkspaceSummaryPageQuerySchema = z.object({ cursor: z.string().uuid().optional(), }).strict(); +export const EXECUTION_WORKSPACE_SUMMARY_IDS_MAX = 100; + +/** `ids` query for workspace summaries: comma-separated UUIDs, 1–100 distinct values. */ +export const executionWorkspaceSummaryIdsQuerySchema = z + .string() + .transform((value) => value.split(",").map((id) => id.trim()).filter((id) => id.length > 0)) + .pipe(z.array(z.string().uuid()).min(1).max(EXECUTION_WORKSPACE_SUMMARY_IDS_MAX)) + .transform((ids) => [...new Set(ids)]); + export const taskWorkspaceSelectionSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("task_directory") }).strict(), z.object({ kind: z.literal("existing"), workspaceId: z.string().uuid() }).strict(), diff --git a/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts index 6815ee0e4a0..46a4c12e383 100644 --- a/server/src/__tests__/execution-workspaces-routes.test.ts +++ b/server/src/__tests__/execution-workspaces-routes.test.ts @@ -140,6 +140,7 @@ describe("execution workspace routes", () => { }, ]); expect(mockExecutionWorkspaceService.listSummaries).toHaveBeenCalledWith("company-1", { + ids: undefined, readCondition: expect.anything(), projectId: undefined, projectWorkspaceId: undefined, @@ -188,6 +189,30 @@ describe("execution workspace routes", () => { expect(mockExecutionWorkspaceService.listSummaries).not.toHaveBeenCalled(); }); + it("passes distinct requested ids to summary reads with the read condition", async () => { + const first = "00000000-0000-4000-8000-000000000001"; + const second = "00000000-0000-4000-8000-000000000002"; + const res = await request(createApp()) + .get(`/api/companies/company-1/execution-workspaces?summary=true&ids=${second},${first},${second}`); + expect(res.status).toBe(200); + expect(mockExecutionWorkspaceService.listSummaries).toHaveBeenCalledWith("company-1", expect.objectContaining({ + ids: [second, first], + readCondition: expect.anything(), + })); + expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled(); + }); + + it.each([ + ["a non-uuid id", "ids=not-a-uuid"], + ["an empty list", "ids="], + ["only separators", "ids=,,"], + ["more than 100 ids", `ids=${Array.from({ length: 101 }, (_, index) => `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`).join(",")}`], + ])("rejects summary ids with %s", async (_label, query) => { + const res = await request(createApp()).get(`/api/companies/company-1/execution-workspaces?summary=true&${query}`); + expect(res.status).toBe(422); + expect(mockExecutionWorkspaceService.listSummaries).not.toHaveBeenCalled(); + }); + it("rejects invalid workspace overview pagination", async () => { const res = await request(createApp()) .get("/api/companies/company-1/workspace-overview?limit=1000"); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 9e114d6ee68..48a39517fb6 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -2717,6 +2717,50 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { ]); }); + it("limits summaries to the requested ids while keeping company scope and the read condition", async () => { + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const projectId = randomUUID(); + const otherProjectId = randomUUID(); + const requestedId = randomUUID(); + const hiddenId = randomUUID(); + const unrequestedId = randomUUID(); + const otherCompanyWorkspaceId = randomUUID(); + + await db.insert(companies).values([ + { id: companyId, name: "Paperclip", issuePrefix: `I${companyId.slice(0, 5).toUpperCase()}`, requireBoardApprovalForNewAgents: false }, + { id: otherCompanyId, name: "Other", issuePrefix: `O${otherCompanyId.slice(0, 5).toUpperCase()}`, requireBoardApprovalForNewAgents: false }, + ]); + await db.insert(projects).values([ + { id: projectId, companyId, name: "Ids", status: "in_progress" }, + { id: otherProjectId, companyId: otherCompanyId, name: "Other ids", status: "in_progress" }, + ]); + const workspace = (id: string, owner: string, project: string, name: string) => ({ + id, + companyId: owner, + projectId: project, + mode: "isolated_workspace", + strategyType: "git_worktree", + name, + status: "active", + providerType: "git_worktree", + cwd: `/tmp/${id}`, + }); + await db.insert(executionWorkspaces).values([ + workspace(requestedId, companyId, projectId, "Requested"), + workspace(hiddenId, companyId, projectId, "Hidden by read condition"), + workspace(unrequestedId, companyId, projectId, "Not requested"), + workspace(otherCompanyWorkspaceId, otherCompanyId, otherProjectId, "Other company"), + ]); + + const summaries = await svc.listSummaries(companyId, { + ids: [requestedId, hiddenId, otherCompanyWorkspaceId], + readCondition: sql`${executionWorkspaces.id} <> ${hiddenId}`, + }); + + expect(summaries.map((summary) => summary.id)).toEqual([requestedId]); + }); + it("reconciles a forward branch record, comments on the source issue, and resolves matching workspace recovery", async () => { const repoRoot = await createTempRepo(); tempDirs.add(repoRoot); diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index ea37c280835..d8998a9c3ba 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -16,6 +16,7 @@ import { reconcileExecutionWorkspaceBranchSchema, updateExecutionWorkspaceSchema, workspaceOverviewQuerySchema, + executionWorkspaceSummaryIdsQuerySchema, executionWorkspaceSummaryPageQuerySchema, workspaceRuntimeControlTargetSchema, } from "@paperclipai/shared"; @@ -134,7 +135,17 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertExecutionWorkspaceReadAllowed(req, res, companyId))) return; + let ids: string[] | undefined; + if (req.query.ids !== undefined) { + const parsedIds = executionWorkspaceSummaryIdsQuerySchema.safeParse(req.query.ids); + if (!parsedIds.success) { + res.status(422).json({ error: "Invalid workspace ids", details: parsedIds.error.flatten() }); + return; + } + ids = parsedIds.data; + } const filters = { + ids, readCondition: await executionWorkspaceReadSqlCondition(db, req.actor), projectId: req.query.projectId as string | undefined, projectWorkspaceId: req.query.projectWorkspaceId as string | undefined, diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 019f9973e61..4b72f1a78e3 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -2764,10 +2764,12 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic reuseEligible?: boolean; selectableForTask?: boolean; readCondition?: SQL; + ids?: string[]; }, ) { const conditions = [eq(executionWorkspaces.companyId, companyId)]; if (filters?.readCondition) conditions.push(filters.readCondition); + if (filters?.ids) conditions.push(inArray(executionWorkspaces.id, filters.ids)); if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId)); if (filters?.projectWorkspaceId) { conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId)); @@ -3054,6 +3056,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic reuseEligible?: boolean; selectableForTask?: boolean; readCondition?: SQL; + ids?: string[]; }, pagination?: { limit: number; cursor?: string }) => { const conditions = buildListConditions(companyId, filters); if (pagination?.cursor) conditions.push(gt(executionWorkspaces.id, pagination.cursor)); diff --git a/ui/src/api/execution-workspaces.test.ts b/ui/src/api/execution-workspaces.test.ts index 6a018701aa5..8946292920f 100644 --- a/ui/src/api/execution-workspaces.test.ts +++ b/ui/src/api/execution-workspaces.test.ts @@ -102,6 +102,31 @@ describe("executionWorkspacesApi.listSummaries", () => { }); }); +describe("executionWorkspacesApi.listSummariesByIds", () => { + beforeEach(() => { + mockApi.get.mockReset(); + mockApi.get.mockImplementation(async (path: string) => { + const ids = new URL(path, "http://localhost").searchParams.get("ids")?.split(",") ?? []; + return ids.map((id) => ({ id, name: id, mode: "isolated_workspace", projectWorkspaceId: null })); + }); + }); + + it("reads id-filtered summaries in chunks the server accepts and concatenates them", async () => { + const ids = Array.from({ length: 205 }, (_, index) => `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`); + + const summaries = await executionWorkspacesApi.listSummariesByIds("company-1", ids); + + const chunks = mockApi.get.mock.calls.map(([path]) => { + const url = new URL(String(path), "http://localhost"); + expect(url.pathname).toBe("/companies/company-1/execution-workspaces"); + expect(url.searchParams.get("summary")).toBe("true"); + return url.searchParams.get("ids")?.split(",") ?? []; + }); + expect(chunks.map((chunk) => chunk.length)).toEqual([100, 100, 5]); + expect(summaries.map((summary) => summary.id)).toEqual(ids); + }); +}); + describe("executionWorkspacesApi.reconcile", () => { beforeEach(() => { mockApi.post.mockReset(); diff --git a/ui/src/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts index 7f90504fd2c..1a47aa30fd0 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -8,6 +8,7 @@ import type { WorkspaceOperation, WorkspaceRuntimeControlTarget, } from "@paperclipai/shared"; +import { EXECUTION_WORKSPACE_SUMMARY_IDS_MAX } from "@paperclipai/shared"; import { api } from "./client"; import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control"; @@ -60,9 +61,11 @@ export const executionWorkspacesApi = { status?: string; reuseEligible?: boolean; selectableForTask?: boolean; + ids?: readonly string[]; }, ) => { const params = new URLSearchParams(); + if (filters?.ids) params.set("ids", filters.ids.join(",")); if (filters?.projectId) params.set("projectId", filters.projectId); if (filters?.projectWorkspaceId) params.set("projectWorkspaceId", filters.projectWorkspaceId); if (filters?.issueId) params.set("issueId", filters.issueId); @@ -75,6 +78,17 @@ export const executionWorkspacesApi = { `/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`, ); }, + /** Summaries for specific workspaces, read in server-sized chunks of ids. */ + listSummariesByIds: async (companyId: string, ids: readonly string[]) => { + const chunks: string[][] = []; + for (let index = 0; index < ids.length; index += EXECUTION_WORKSPACE_SUMMARY_IDS_MAX) { + chunks.push(ids.slice(index, index + EXECUTION_WORKSPACE_SUMMARY_IDS_MAX)); + } + const pages = await Promise.all( + chunks.map((chunk) => executionWorkspacesApi.listSummaries(companyId, { ids: chunk })), + ); + return pages.flat(); + }, list: ( companyId: string, filters?: { diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index d941eeb682c..ded42d1a8cc 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -751,6 +751,17 @@ export function getUnreadTouchedIssues(issues: Issue[]): Issue[] { return issues.filter((issue) => issue.isUnreadForMe); } +/** Sorted distinct execution workspaces the given issue lists reference. */ +export function collectReferencedExecutionWorkspaceIds( + ...issueLists: ReadonlyArray>> +): string[] { + const ids = new Set(); + for (const list of issueLists) { + for (const issue of list) if (issue.executionWorkspaceId) ids.add(issue.executionWorkspaceId); + } + return [...ids].sort(); +} + export function getApprovalsForTab( approvals: Approval[], tab: InboxTab, diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index fc6e2c68991..91d05505682 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -73,7 +73,10 @@ vi.mock("../api/dashboard", () => ({ })); vi.mock("../api/execution-workspaces", () => ({ - executionWorkspacesApi: { listSummaries: apiMocks.executionWorkspaceSummaries }, + executionWorkspacesApi: { + listSummariesByIds: (companyId: string, ids: readonly string[]) => + apiMocks.executionWorkspaceSummaries(companyId, { ids }), + }, })); vi.mock("../api/issues", () => ({ @@ -132,6 +135,20 @@ vi.mock("../context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false }), })); +const workspaceIdScan = vi.hoisted(() => ({ calls: 0, limit: Number.POSITIVE_INFINITY })); + +vi.mock("../lib/inbox", async () => { + const actual = await vi.importActual("../lib/inbox"); + return { + ...actual, + collectReferencedExecutionWorkspaceIds: (...lists: Array>>) => { + workspaceIdScan.calls += 1; + if (workspaceIdScan.calls > workspaceIdScan.limit) throw new Error("Workspace id scan ran on an unchanged render"); + return actual.collectReferencedExecutionWorkspaceIds(...lists); + }, + }; +}); + vi.mock("../hooks/useInboxBadge", () => ({ useDismissedInboxAlerts: () => ({ dismissed: new Set(), dismiss: vi.fn() }), useInboxDismissals: () => ({ dismissedAtByKey: new Map(), dismiss: vi.fn() }), @@ -901,6 +918,8 @@ describe("Inbox toolbar", () => { await vi.waitFor(() => { expect(container.querySelector('[data-slot="task-row"]')?.textContent).toContain("Workspace Aurora"); }); + // Only the workspaces the rows reference are read, never the company-wide list. + expect(apiMocks.executionWorkspaceSummaries).toHaveBeenCalledWith("company-1", { ids: ["execution-workspace-1"] }); const taskRow = container.querySelector('[data-slot="task-row"]'); const identifier = taskRow?.querySelector('[data-slot="task-row-identifier"]'); @@ -914,6 +933,185 @@ describe("Inbox toolbar", () => { act(() => root.unmount()); }); + it("scans a realistic Mine list for workspace ids once per data change, not per render", async () => { + routerMock.location.pathname = "/inbox/mine"; + localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["status", "id", "updated"])); + apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: true }); + const base = new Date("2026-03-11T00:00:00.000Z").getTime(); + const issues = Array.from({ length: 100 }, (_, index) => createIssue({ + id: `issue-${index}`, + identifier: `PAP-${5000 + index}`, + title: `Scaled task ${index}`, + executionWorkspaceId: `execution-workspace-${String(index).padStart(3, "0")}`, + lastActivityAt: new Date(base - index * 1000), + })); + setIssueRows(issues); + workspaceIdScan.calls = 0; + workspaceIdScan.limit = Number.POSITIVE_INFINITY; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + + try { + await act(async () => { + root.render(); + }); + await vi.waitFor(() => expect(container.querySelectorAll('[data-slot="task-row"]').length).toBe(100)); + await vi.waitFor(() => expect(apiMocks.executionWorkspaceSummaries).toHaveBeenCalled()); + expect(apiMocks.executionWorkspaceSummaries).toHaveBeenCalledTimes(1); + expect(apiMocks.executionWorkspaceSummaries.mock.calls[0]?.[1]).toEqual({ + ids: issues.map((issue) => issue.executionWorkspaceId), + }); + + // Keyboard navigation re-renders the page without changing its data. + workspaceIdScan.limit = workspaceIdScan.calls; + await act(async () => { + for (let press = 0; press < 5; press += 1) { + document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "j", bubbles: true })); + } + }); + expect(workspaceIdScan.calls).toBe(workspaceIdScan.limit); + expect(apiMocks.executionWorkspaceSummaries).toHaveBeenCalledTimes(1); + } finally { + workspaceIdScan.limit = Number.POSITIVE_INFINITY; + act(() => root.unmount()); + } + }); + + it("rows reveal while the workspace read is pending when no workspace column or grouping is active", async () => { + routerMock.location.pathname = "/inbox/mine"; + localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["status", "id", "updated"])); + localStorage.setItem("paperclip:inbox:group-by", "none"); + apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: true }); + apiMocks.executionWorkspaceSummaries.mockReturnValue(new Promise(() => {})); + setIssueRows([createIssue({ + executionWorkspaceId: "execution-workspace-1", + title: "Row without workspace column", + })]); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, + }); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + await vi.waitFor(() => { + expect(container.querySelector('[data-slot="task-row"]')?.textContent).toContain("Row without workspace column"); + }); + expect(apiMocks.executionWorkspaceSummaries).toHaveBeenCalledWith("company-1", { ids: ["execution-workspace-1"] }); + + act(() => root.unmount()); + }); + + it("keeps a new view hidden while previous workspace summaries stand in for its ids", async () => { + routerMock.location.pathname = "/inbox/mine"; + localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["status", "id", "workspace", "updated"])); + apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: true }); + mineFixture = { ...mineFixture, issues: [createIssue({ id: "issue-mine", identifier: "PAP-6001", title: "Mine view task", executionWorkspaceId: "execution-workspace-1" })] }; + apiMocks.issuesList.mockResolvedValue([ + createIssue({ id: "issue-recent", identifier: "PAP-6002", title: "Recent view task", executionWorkspaceId: "execution-workspace-2" }), + ]); + const summary = (id: string, name: string) => ({ id, name, mode: "isolated_workspace", projectWorkspaceId: null }); + let resolveSecond: () => void = () => {}; + apiMocks.executionWorkspaceSummaries.mockImplementation(async (_companyId: string, { ids }: { ids: string[] }) => { + if (ids.includes("execution-workspace-2")) await new Promise((resolve) => { resolveSecond = resolve; }); + return ids.map((id) => summary(id, id === "execution-workspace-1" ? "Workspace One" : "Workspace Two")); + }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + const render = () => root.render(); + + try { + await act(async () => { render(); }); + await vi.waitFor(() => expect(container.querySelector('[data-slot="task-row"]')?.textContent).toContain("Workspace One")); + + routerMock.location.pathname = "/inbox/recent"; + await act(async () => { render(); }); + await vi.waitFor(() => { + expect(apiMocks.executionWorkspaceSummaries.mock.calls.some(([, filters]) => + (filters as { ids: string[] }).ids.includes("execution-workspace-2"))).toBe(true); + }); + expect(container.textContent).not.toContain("Recent view task"); + + await act(async () => { resolveSecond(); }); + await vi.waitFor(() => { + const recentRow = [...container.querySelectorAll('[data-slot="task-row"]')] + .find((row) => row.textContent?.includes("Recent view task")); + expect(recentRow?.textContent).toContain("Workspace Two"); + }); + } finally { + act(() => root.unmount()); + } + }); + + it("reveals another company's rows that reference no workspaces after a workspace view loaded", async () => { + routerMock.location.pathname = "/inbox/mine"; + localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["status", "id", "workspace", "updated"])); + apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: true }); + setIssueRows([createIssue({ id: "issue-first", identifier: "PAP-7001", title: "First company task", executionWorkspaceId: "execution-workspace-1" })]); + apiMocks.executionWorkspaceSummaries.mockResolvedValue([ + { id: "execution-workspace-1", name: "Workspace One", mode: "isolated_workspace", projectWorkspaceId: null }, + ]); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + const render = () => root.render(); + + try { + await act(async () => { render(); }); + await vi.waitFor(() => expect(container.querySelector('[data-slot="task-row"]')?.textContent).toContain("Workspace One")); + + setIssueRows([createIssue({ id: "issue-second", identifier: "PAP-7002", title: "Second company task", companyId: "company-2" })]); + companyState.selectedCompanyId = "company-2"; + await act(async () => { render(); }); + await vi.waitFor(() => expect(container.textContent).toContain("Second company task")); + expect(container.textContent).not.toContain("Workspace One"); + expect(apiMocks.executionWorkspaceSummaries).toHaveBeenCalledTimes(1); + } finally { + act(() => root.unmount()); + } + }); + + it("keeps rows hidden until referenced workspaces load when the Workspace column is shown", async () => { + routerMock.location.pathname = "/inbox/mine"; + localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["status", "id", "workspace", "updated"])); + apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: true }); + let resolveWorkspaces: (value: unknown) => void = () => {}; + apiMocks.executionWorkspaceSummaries.mockReturnValue(new Promise((resolve) => { resolveWorkspaces = resolve; })); + setIssueRows([createIssue({ + executionWorkspaceId: "execution-workspace-1", + title: "Row with workspace column", + })]); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, + }); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + await vi.waitFor(() => { + expect(apiMocks.executionWorkspaceSummaries).toHaveBeenCalledWith("company-1", { ids: ["execution-workspace-1"] }); + }); + expect(container.querySelector('[data-slot="task-row"]')).toBeNull(); + + await act(async () => { + resolveWorkspaces([{ id: "execution-workspace-1", name: "Workspace Borealis", mode: "isolated_workspace", projectWorkspaceId: null }]); + }); + await vi.waitFor(() => { + expect(container.querySelector('[data-slot="task-row"]')?.textContent).toContain("Workspace Borealis"); + }); + + act(() => root.unmount()); + }); + it("hides workspace grouping when isolated workspaces are disabled", async () => { routerMock.location.pathname = "/inbox/mine"; apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false }); diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 4ea945e8e52..116920d3e54 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -147,6 +147,7 @@ import { getInboxWorkItems, getInboxSearchSupplementIssues, getLatestFailedRunsByAgent, + collectReferencedExecutionWorkspaceIds, matchesInboxIssueSearch, getRecentTouchedIssues, isInboxEntityDismissed, @@ -202,6 +203,8 @@ import { taskStatusIconProps } from "@/lib/task-status-presentation"; const INBOX_HEARTBEAT_RUN_LIMIT = 200; const INBOX_ISSUE_LIST_LIMIT = 100; const INBOX_HOT_PATH_STALE_MS = 30_000; +// Stable fallback so memos over unloaded or disabled issue reads do not rerun on every render. +const NO_ISSUES: Issue[] = []; const INBOX_COLLECTION_KEY = "inbox"; type StreamlinedInboxViewState = InboxFilterPreferences & { @@ -930,13 +933,6 @@ function StreamlinedInbox({ }); const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; const externalObjectsEnabled = experimentalSettings?.enableExternalObjects === true; - const { data: executionWorkspaces = [], isLoading: isWorkspacesLoading } = useQuery({ - queryKey: selectedCompanyId - ? queryKeys.executionWorkspaces.summaryList(selectedCompanyId) - : ["execution-workspaces", "__disabled__"], - queryFn: () => executionWorkspacesApi.listSummaries(selectedCompanyId!), - enabled: !!selectedCompanyId && isolatedWorkspacesEnabled, - }); useEffect(() => { setBreadcrumbs([{ label: surfaceLabel }]); @@ -1026,7 +1022,7 @@ function StreamlinedInbox({ error && isTransientError(new ApiError(error.message, error.status, null))) ? 5_000 : false; }, }); - const { data: mineIssuesRaw = [], isLoading: isMineIssuesLoading, error: mineIssuesError } = mineIssuesQuery; + const { data: mineIssuesRaw = NO_ISSUES, isLoading: isMineIssuesLoading, error: mineIssuesError } = mineIssuesQuery; const mineIssuesView = useQueryView(mineIssuesQuery); const mineIssuesQueryKey = [...queryKeys.issues.listMineByMe(selectedCompanyId!), "index", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT, currentUserId] as const; // Mine owns one coherent response: broadcasting only its task array would @@ -1050,7 +1046,7 @@ function StreamlinedInbox({ refetchOnWindowFocus: false, staleTime: INBOX_HOT_PATH_STALE_MS, }); - const { data: touchedIssuesRaw = [], isLoading: isTouchedIssuesLoading, error: touchedIssuesError, dataUpdatedAt: touchedIssuesUpdatedAt } = touchedIssuesQuery; + const { data: touchedIssuesRaw = NO_ISSUES, isLoading: isTouchedIssuesLoading, error: touchedIssuesError, dataUpdatedAt: touchedIssuesUpdatedAt } = touchedIssuesQuery; const touchedIssuesView = useQueryView(touchedIssuesQuery); const touchedIssuesQueryKey = [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "index", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const; const sharedTouchedIssues = useSharedPollingQuery({ @@ -1219,7 +1215,7 @@ function StreamlinedInbox({ const shouldUseIssueSearchSupplement = !!selectedCompanyId && normalizedSearchQuery.length > 0; - const { data: remoteIssueSearchResults = [] } = useQuery({ + const { data: remoteIssueSearchResults = NO_ISSUES } = useQuery({ queryKey: [ ...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25), "compact", @@ -1254,6 +1250,28 @@ function StreamlinedInbox({ for (const issue of remoteIssueSearchResults) issueIds.add(issue.id); return [...issueIds]; }, [mineIssues, remoteIssueSearchResults, touchedIssues]); + // Only the workspaces these rows reference; a company-wide list can be thousands of rows. + const referencedExecutionWorkspaceIds = useMemo( + () => collectReferencedExecutionWorkspaceIds(issues, remoteIssueSearchResults), + [issues, remoteIssueSearchResults], + ); + const referencedExecutionWorkspaceIdsKey = referencedExecutionWorkspaceIds.join(","); + const workspaceLookupEnabled = !!selectedCompanyId && isolatedWorkspacesEnabled && referencedExecutionWorkspaceIds.length > 0; + const { + data: executionWorkspaces = [], + isLoading: isWorkspacesLoading, + isPlaceholderData: isWorkspacesPlaceholder, + } = useQuery({ + queryKey: queryKeys.executionWorkspaces.summaryList(selectedCompanyId ?? "__disabled__", { + ids: referencedExecutionWorkspaceIdsKey, + }), + queryFn: () => executionWorkspacesApi.listSummariesByIds(selectedCompanyId!, referencedExecutionWorkspaceIds), + enabled: workspaceLookupEnabled, + // Keep names visible while this company's id set changes; never carry them across companies + // or into a view that needs no lookup, where a placeholder would never be replaced. + placeholderData: (previous, previousQuery) => + workspaceLookupEnabled && previousQuery?.queryKey[1] === selectedCompanyId ? previous : undefined, + }); const { summaries: externalObjectSummaryByIssueId, isLoading: externalObjectSummariesLoading, @@ -2534,9 +2552,16 @@ function StreamlinedInbox({ showWorkItemsSection ? "work_items" : null, ].filter((key): key is SectionKey => key !== null); + // Workspace names change what rows show (grouping, the Workspace column) or which rows match a + // search, so wait for them only then; otherwise rows reveal without the workspace read. + const workspaceDataAffectsRows = groupBy === "workspace" + || (visibleIssueColumnSet.has("workspace") && availableIssueColumnSet.has("workspace")) + || normalizedSearchQuery.length > 0; const initialSourcesSettled = ![sourceSearchView, mineIssuesView, touchedIssuesView].some(view => view.kind === "reconnecting") && - !isSourceSearchLoading && !isSessionLoading && !isSettingsLoading && !isDismissalsLoading && !isAgentsLoading && !isProjectsLoading && !isMembersLoading && !isWorkspacesLoading && + !isSourceSearchLoading && !isSessionLoading && !isSettingsLoading && !isDismissalsLoading && !isAgentsLoading && !isProjectsLoading && !isMembersLoading && + // Previous summaries stand in while a new id set loads; they may lack the new view's workspaces. + !(workspaceDataAffectsRows && workspaceLookupEnabled && (isWorkspacesLoading || isWorkspacesPlaceholder)) && !isJoinRequestsLoading && !isApprovalsLoading && !isDashboardLoading &&