mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
perf(inbox): read only the execution workspaces Mine rows reference (#20388)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Mine inbox is the first page a board user opens to see the tasks that need them > - The page loaded the company's full execution-workspace summary list and kept task rows hidden until that list arrived > - On a company with thousands of workspaces, that read checks each workspace row against the privacy predicate. It was the slowest request on the page > - The inbox needs workspace names only for the workspaces its rows reference, and only when the view shows workspace data > - This pull request adds an `ids` filter to the summary read, makes the inbox request only referenced workspaces, and stops rows from waiting for workspace data when the view does not show it > - The benefit is that Mine rows appear when the task data arrives, and the workspace read becomes a small bounded query ## Linked Issues or Issue Description **What existing behavior does this improve?** The Mine inbox (`/:companyPrefix/issues?view=mine`) shows its task rows late on companies with many execution workspaces. **Subsystem affected** Board UI inbox and the execution-workspace summary API. **Current behavior** The page calls `GET /api/companies/:id/execution-workspaces?summary=true` with no filter. On a large company this returned 8,758 rows (2.5 MB) and took 12.3 s on the server. The reveal gate included this read, so rows appeared at 13.4 s although `inbox/mine` arrived at 1.6 s. **Proposed behavior** The inbox requests summaries only for the workspaces its rows reference. Rows appear when the Mine task data arrives, unless the view shows workspace data. **Reason and benefit** The workspace read becomes a small bounded query, and it leaves the critical path for the default Mine view. **Breaking changes** None. The `ids` parameter is optional and additive. ## What Changed - `packages/shared`: add `executionWorkspaceSummaryIdsQuerySchema` (comma-separated UUIDs, 1–100 distinct values) and `EXECUTION_WORKSPACE_SUMMARY_IDS_MAX`. - `server`: `GET /companies/:companyId/execution-workspaces` accepts `ids`. Bad input returns 422. The service adds `inArray(execution_workspaces.id, ids)`. The company scope and the actor read condition still apply, so privacy does not change. - `ui/src/api/execution-workspaces.ts`: `listSummaries` accepts `ids`. New `listSummariesByIds` reads ids in chunks of 100 and concatenates the results. - `ui/src/pages/Inbox.tsx`: the workspace query is keyed on the sorted distinct `executionWorkspaceId`s of the rows the inbox holds (Mine, touched, context and remote search issues). It uses `keepPreviousData` and runs only when isolated workspaces are on and at least one id exists. - Reveal gate: rows wait for the workspace read only when workspace data changes what the rows show. This is workspace grouping, a visible Workspace column, or an active search (workspace names are searchable). Otherwise rows reveal without it. - Tests: route ids pass-through and 422 cases (non-uuid, empty, separators only, more than 100); service test for the id filter with company scope and the read condition; UI api chunking test; Inbox tests for "rows reveal while the workspace read is pending when no workspace column or grouping is active", rows waiting when the Workspace column is visible, and "shows the resolved isolated workspace name" with the id-filtered call. Follow-ups (not changed here, they still read the unfiltered list): - `ui/src/pages/LegacyInbox.tsx:772` - `ui/src/components/IssuesList.tsx:985` - `ui/src/components/LegacyIssuesList.tsx:888` - `ui/src/components/NewIssueDialog.tsx:570` (filtered by project and reuse, but not by ids) ## Verification Focused checks on this revision: - `cd server && npx vitest run src/__tests__/execution-workspaces-routes.test.ts src/__tests__/execution-workspaces-service.test.ts -t "summar|ids"`: 14 passed (embedded Postgres ran the service test). - `cd ui && npx vitest run src/api/execution-workspaces.test.ts src/pages/Inbox.test.tsx`: all passed. - `pnpm --filter @paperclipai/server --filter @paperclipai/ui --filter @paperclipai/shared typecheck`: passed. - `pnpm check:token-gates`: clean. - `pnpm verify` selected 2,481 Vitest files (the shared validator change reaches most suites). I stopped it at a 25-minute limit on a heavily loaded shared host. Before the limit, three server tests outside this change failed: `agent-commentary-skills` (skill mount), `agent-config-read-access` (24.8 s timeout) and `agent-directory-working-copies`. Required CI supplies the full coverage. - Review fix `fc3f82dae0`: when workspace data is visible, the reveal gate also waits while `keepPreviousData` supplies the previous id set's summaries. A new test switches Mine → Recent to a new workspace and checks the Recent row stays hidden until its workspace loads. It fails without the change. - Follow-up commits `bf34f84823` and `8fc743b5ca` add a 100-row Mine test with a counted workspace id scan. It fails with the old `= []` defaults and passes with the stable `NO_ISSUES` default. Performance (local, synthetic fixture): - Instance: isolated worktree instance, `authenticated` mode, signed in as a non-admin company member so the real workspace privacy predicate runs. Production UI bundle (`PAPERCLIP_UI_DEV_MIDDLEWARE=false`). Headless Chromium 1440×900, a new browser context per run (cold HTTP cache and cold query cache). macOS, Apple Silicon, with other local sessions running. - Fixture: 8,750 execution workspaces (each with a source task and a privacy-source key), 38,750 tasks, 150 Mine tasks; the Mine snapshot returns 100 rows that reference 100 workspaces. - Finish event: the first `[data-slot="task-row"]` is in the DOM. | Measurement | Before (`02bfe71a6b`) | After | | --- | --- | --- | | Cold time to rows, median of 7 | 1,209 ms (1,046–1,312) | 702 ms (567–1,209) | | Workspace summary read, server wait (time to first byte), median of 7 | 496 ms unfiltered, 2.0 MB | 18 ms with 100 ids, 22 KB | The difference of medians is 507 ms. This is close to the unfiltered read's wait on this fixture (about 550–590 ms in the browser runs). The runs were sequential batches, not alternating pairs. Limits: this fixture does not reproduce the 12.3 s server time seen on the development instance. Here the unfiltered read cost 0.5–1.7 s and grew with the company's task count. The browser gain on the original instance should be close to that instance's unfiltered read time, but I did not measure it there. ## Risks - Low risk. The `ids` filter only narrows the existing query. It does not change the read condition or the company scope. - A row whose workspace is not readable by the actor shows no workspace name, as before. - When the Workspace column or grouping is visible, rows still wait for the workspace read. That read is now small. - When the id set changes, the query refetches all chunks. Before a view first reveals, rows wait for the new summaries if workspace data is visible. After that, the previous names stay visible while a refresh loads. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edit, headless browser). Extended reasoning on. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1c868b86be
commit
50df9a19b9
10 files changed
+377
-12
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<boolean>`${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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2764,10 +2764,12 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
reuseEligible?: boolean;
|
||||
selectableForTask?: boolean;
|
||||
readCondition?: SQL<boolean>;
|
||||
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<boolean>;
|
||||
ids?: string[];
|
||||
}, pagination?: { limit: number; cursor?: string }) => {
|
||||
const conditions = buildListConditions(companyId, filters);
|
||||
if (pagination?.cursor) conditions.push(gt(executionWorkspaces.id, pagination.cursor));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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<ReadonlyArray<Pick<Issue, "executionWorkspaceId">>>
|
||||
): string[] {
|
||||
const ids = new Set<string>();
|
||||
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,
|
||||
|
||||
+199
-1
@@ -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<typeof import("../lib/inbox")>("../lib/inbox");
|
||||
return {
|
||||
...actual,
|
||||
collectReferencedExecutionWorkspaceIds: (...lists: Array<ReadonlyArray<Pick<Issue, "executionWorkspaceId">>>) => {
|
||||
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(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>);
|
||||
});
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Inbox />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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<void>((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(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>);
|
||||
|
||||
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(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>);
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Inbox />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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 });
|
||||
|
||||
+36
-11
@@ -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<Issue[]>({
|
||||
@@ -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 &&
|
||||
|
||||
Reference in new issue
Block a user