perf(ui): read skills and routines only when an editor mounts (#20392)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI wraps every page in an `EditorAutocompleteProvider`
that supplies slash commands to Markdown editors
> - The provider read the full company skills and routines lists as soon
as a company was selected, on every page
> - The Mine inbox has no editor, but it still paid for both reads:
259–284 KB for skills and 225–333 KB for routines on our dev and staging
instances, with skills taking 8.5–13.7 s on a saturated staging server
> - Those reads compete with the inbox rows for the same server
> - This pull request starts the two reads only when the first Markdown
editor mounts
> - The benefit is two fewer large reads on every page without an
editor, and no change for pages that have one

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The requests that every board UI page sends on load.

**Subsystem affected**
Board UI context (`ui/src/context/EditorAutocompleteContext.tsx`).

**Current behavior**
A cold load of `/:prefix/issues?view=mine` sends `GET
/companies/:id/skills` and `GET /companies/:id/routines` although
nothing on the page renders them.

**Proposed behavior**
The provider keeps both queries disabled until a component calls
`useEditorAutocomplete()`. `MarkdownEditor` is the only caller, so the
reads start when an editor mounts: a task composer, the new-task dialog
when it opens, a comment box.

**Reason and benefit**
Each page without an editor saves two large reads. Pages with an editor
read the same data at mount, before a user can type `/`.

## What Changed

- `EditorAutocompleteProvider` holds a `requested` flag. Both queries
are `enabled` only after it is set.
- `useEditorAutocomplete()` sets the flag in an effect on first use. The
flag stays set, so the lists stay current through the existing
live-update invalidations for skills and routines.
- New unit test `EditorAutocompleteContext.test.tsx`: no reads without
an editor; one read of each list when two editors mount.
- New browser check `tests/perf/collections/shell-editor-reads.spec.ts`:
a Mine load sends no `skills` or `routines` request.

## Verification

- `pnpm verify` passes.
- `ui`: `npx vitest run src/context/EditorAutocompleteContext.test.tsx
src/components/task-chat/TaskChatComposer.test.tsx` passes.
- Browser check against a disposable local instance:
`PAPERCLIP_COLLECTION_PERF_BASE_URL=http://127.0.0.1:<port> pnpm exec
playwright test --config tests/perf/collections/playwright.config.ts
shell-editor-reads.spec.ts`. It passes on this branch and fails on
`master`.
- Manual: on a local instance the Mine load dropped from 34 to 32 API
requests. Opening a task shows the composer; `/` lists skills and
routines as before.

## Risks

- Low risk. On the first editor mount the slash menu is empty until the
two reads return. On `master` the same reads ran at app start, so a user
who typed `/` within the first moments of a page with an editor saw the
same gap.
- `attention/summary` is not changed. The navigation's "Needs you" count
renders it on the Mine page.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, browser). 1M-token context window. Extended
thinking was off.

## 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 17:42:39 -06:00
1 parent 7e93ccb4b3
commit c7eb35809e
3 files changed
+136 -5

No files matched your search

@@ -0,0 +1,21 @@
import { expect, test } from "@playwright/test";
import { collectionFixture, type CollectionFixture } from "./fixture";
let fixture: CollectionFixture;
test.beforeAll(async ({ request }) => {
fixture = await collectionFixture(request);
});
// Slash-command options for Markdown editors come from the company skills and
// routines lists. Mine has no editor, so it must not pay for either read.
test("Mine does not read company skills or routines", async ({ page }) => {
const companyId = fixture.company.id;
const reads: URL[] = [];
page.on("request", item => { if (item.url().includes("/api/")) reads.push(new URL(item.url())); });
await page.goto(`/${fixture.company.issuePrefix}/issues?view=mine`);
await expect.poll(() => reads.some(url => url.pathname === `/api/companies/${companyId}/inbox/mine`)).toBe(true);
// Shell reads start in the same pass as the inbox; allow late ones to start.
await page.waitForTimeout(2_000);
expect(reads.filter(url => url.pathname === `/api/companies/${companyId}/skills`)).toEqual([]);
expect(reads.filter(url => url.pathname === `/api/companies/${companyId}/routines`)).toEqual([]);
});
@@ -0,0 +1,92 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listSkills: vi.fn(),
listRoutines: vi.fn(),
}));
vi.mock("../api/companySkills", () => ({ companySkillsApi: { list: mocks.listSkills } }));
vi.mock("../api/routines", () => ({ routinesApi: { list: mocks.listRoutines } }));
vi.mock("./CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1" }) }));
import { EditorAutocompleteProvider, useEditorAutocomplete } from "./EditorAutocompleteContext";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
describe("EditorAutocompleteProvider", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
let queryClient: QueryClient;
beforeEach(() => {
mocks.listSkills.mockReset().mockResolvedValue([
{ id: "skill-1", key: "deploy", name: "Deploy", slug: "deploy", description: null },
]);
mocks.listRoutines.mockReset().mockResolvedValue([
{ id: "routine-1", title: "Nightly", status: "active" },
{ id: "routine-2", title: "Old", status: "archived" },
]);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
queryClient.clear();
});
it("does not read skills or routines until an editor asks for slash commands", async () => {
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<EditorAutocompleteProvider>
<p>Mine inbox without an editor</p>
</EditorAutocompleteProvider>
</QueryClientProvider>,
);
});
await flush();
expect(mocks.listSkills).not.toHaveBeenCalled();
expect(mocks.listRoutines).not.toHaveBeenCalled();
});
it("reads both lists once when editors mount and exposes the commands", async () => {
const seen: string[][] = [];
function Editor() {
const { slashCommands } = useEditorAutocomplete();
seen.push(slashCommands.map((command) => command.id));
return null;
}
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<EditorAutocompleteProvider>
<Editor />
<Editor />
</EditorAutocompleteProvider>
</QueryClientProvider>,
);
});
await vi.waitFor(() => expect(seen.at(-1)).toEqual(["skill:skill-1", "routine:routine-1"]));
expect(mocks.listSkills).toHaveBeenCalledTimes(1);
expect(mocks.listSkills).toHaveBeenCalledWith("company-1");
expect(mocks.listRoutines).toHaveBeenCalledTimes(1);
});
});
+23 -5
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { buildRoutineMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
import { companySkillsApi } from "../api/companySkills";
@@ -43,27 +43,38 @@ export type SlashCommandOption = SkillCommandOption | RoutineCommandOption | Act
interface EditorAutocompleteContextValue {
slashCommands: SlashCommandOption[];
/** Starts the skills and routines reads. Editors call it when they mount. */
requestSlashCommands: () => void;
}
const EditorAutocompleteContext = createContext<EditorAutocompleteContextValue>({
slashCommands: [],
requestSlashCommands: () => {},
});
/**
* Slash-command options for Markdown editors. The company skills and routines
* lists are large, and most pages have no editor, so the provider reads them
* only after the first editor mounts. Once read they stay current through the
* live-update invalidations for skills and routines.
*/
export function EditorAutocompleteProvider({ children }: { children: ReactNode }) {
const { selectedCompanyId } = useCompany();
const [requested, setRequested] = useState(false);
const requestSlashCommands = useCallback(() => setRequested(true), []);
const { data: companySkills = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.companySkills.list(selectedCompanyId)
: ["company-skills", "__none__"],
queryFn: () => companySkillsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
enabled: requested && Boolean(selectedCompanyId),
});
const { data: routines = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.routines.list(selectedCompanyId)
: ["routines", "__none__", "__all-projects__"],
queryFn: () => routinesApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
enabled: requested && Boolean(selectedCompanyId),
});
const value = useMemo<EditorAutocompleteContextValue>(() => ({
@@ -92,7 +103,8 @@ export function EditorAutocompleteProvider({ children }: { children: ReactNode }
aliases: [`routine:${routine.title}`, routine.title, routine.id],
})),
],
}), [companySkills, routines]);
requestSlashCommands,
}), [companySkills, requestSlashCommands, routines]);
return (
<EditorAutocompleteContext.Provider value={value}>
@@ -101,6 +113,12 @@ export function EditorAutocompleteProvider({ children }: { children: ReactNode }
);
}
/** Returns the slash commands and starts their reads on first use. */
export function useEditorAutocomplete() {
return useContext(EditorAutocompleteContext);
const value = useContext(EditorAutocompleteContext);
const { requestSlashCommands } = value;
useEffect(() => {
requestSlashCommands();
}, [requestSlashCommands]);
return value;
}