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:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-09 18:02:55 -06:00
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);
+11
View File
@@ -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));
+25
View File
@@ -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();
+14
View File
@@ -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?: {
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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 &&