mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## 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>
93 lines
3.0 KiB
TypeScript
93 lines
3.0 KiB
TypeScript
// @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);
|
|
});
|
|
});
|