mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Issue and item detail pages use a shared issue chat thread to show comments, runs, activity, and interactions. > - That thread still defaulted to landing on the latest comment when messages first loaded. > - On long issue/item pages, that default can yank the operator away from the top of the page before they choose to inspect the newest message. > - Deep links to comment hashes can create the same kind of initial viewport jump when they are used as generic navigation targets. > - This pull request makes initial latest-comment and initial thread-hash scrolling opt-in instead of default behavior. > - The benefit is stable initial page position across issue-thread surfaces while keeping the explicit Jump to latest control available. ## Linked Issues or Issue Description No exact public GitHub issue was found for this bug. Bug description: - What happened: opening a page with a shared issue conversation thread could automatically move the viewport toward the newest comment/thread target. - Expected behavior: ordinary page loads should keep the initial viewport stable unless the user explicitly clicks Jump to latest. - Steps to reproduce: open an issue or item detail page with a long conversation thread and observe whether the page jumps to the newest thread entry on initial load. - Paperclip version/commit: reproduced while working on the current `master` branch lineage. - Deployment mode: local trusted/dev UI. Related public thread/comment UX work: Refs #3916, Refs #7972, Refs #8800. ## What Changed - Changed `IssueChatThread` so initial latest-comment scrolling defaults to off. - Added a separate opt-in for initial thread-hash scrolling, also defaulting to off. - Preserved stale deleted-comment hash cleanup without scrolling the page. - Updated regression coverage so default initial load stays put, comment hashes do not scroll by default, and manual Jump to latest still scrolls. ## Verification - `pnpm --filter @paperclipai/ui typecheck` passed on the clean PR branch. - `pnpm --dir ui exec vitest run src/pages/IssueDetail.test.tsx -t "loads from the pending state into issue detail without changing hook order"` passed on the clean PR branch. - `pnpm --dir ui exec vitest run src/components/IssueChatThread.test.tsx` was attempted on the clean PR branch, but the file fails before changed assertions with the existing `TypeError: act is not a function` test-harness issue across 58 tests; 14 tests passed. - Static check: no `autoScrollToLatestOnInitialLoad={true}` or `autoScrollToHashOnInitialLoad={true}` call sites remain in `ui/src`. ## Risks Low risk. This only changes initial scroll defaults in the shared issue thread. The main behavioral shift is that direct comment/thread hashes no longer auto-scroll on first load unless a caller explicitly opts in; the Jump to latest button and post-submit scroll behavior are unchanged. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 via Codex coding-agent runtime; exact context window not exposed in this environment; tool-enabled repository inspection, editing, testing, git, GitHub CLI, and Paperclip API usage. ## 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 - [ ] 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
110 lines
5.4 KiB
TypeScript
110 lines
5.4 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
listPaperclipSkillEntries,
|
|
removeMaintainerOnlySkillSymlinks,
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
|
|
|
async function makeTempDir(prefix: string): Promise<string> {
|
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
}
|
|
|
|
describe("paperclip skill utils", () => {
|
|
const cleanupDirs = new Set<string>();
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
cleanupDirs.clear();
|
|
});
|
|
|
|
it("lists bundled runtime skills from ./skills without pulling in .agents/skills", async () => {
|
|
const root = await makeTempDir("paperclip-skill-roots-");
|
|
cleanupDirs.add(root);
|
|
|
|
const moduleDir = path.join(root, "a", "b", "c", "d", "e");
|
|
await fs.mkdir(moduleDir, { recursive: true });
|
|
await fs.mkdir(path.join(root, "skills", "paperclip"), { recursive: true });
|
|
await fs.mkdir(path.join(root, "skills", "paperclip-create-agent"), { recursive: true });
|
|
await fs.mkdir(path.join(root, ".agents", "skills", "diagnose-why-work-stopped"), { recursive: true });
|
|
await fs.mkdir(path.join(root, ".agents", "skills", "paperclip-create-plugin"), { recursive: true });
|
|
await fs.mkdir(path.join(root, ".agents", "skills", "release"), { recursive: true });
|
|
await fs.mkdir(path.join(root, ".agents", "skills", "terminal-bench-loop"), { recursive: true });
|
|
|
|
const entries = await listPaperclipSkillEntries(moduleDir);
|
|
|
|
expect(entries.map((entry) => entry.key)).toEqual([
|
|
"paperclipai/paperclip/paperclip",
|
|
"paperclipai/paperclip/paperclip-create-agent",
|
|
]);
|
|
expect(entries.map((entry) => entry.runtimeName)).toEqual([
|
|
"paperclip",
|
|
"paperclip-create-agent",
|
|
]);
|
|
expect(entries[0]?.source).toBe(path.join(root, "skills", "paperclip"));
|
|
expect(entries[1]?.source).toBe(path.join(root, "skills", "paperclip-create-agent"));
|
|
});
|
|
|
|
it("documents artifact uploads in the installed Paperclip skill", async () => {
|
|
const skillBody = await fs.readFile(path.resolve("skills/paperclip/SKILL.md"), "utf8");
|
|
const referenceBody = await fs.readFile(path.resolve("skills/paperclip/references/artifacts.md"), "utf8");
|
|
|
|
expect(skillBody).toContain("Generated Artifacts and Work Products");
|
|
expect(skillBody).toContain("references/artifacts.md");
|
|
expect(skillBody).not.toContain("/api/companies/$PAPERCLIP_COMPANY_ID/issues/$PAPERCLIP_TASK_ID/attachments");
|
|
expect(referenceBody).toContain("Generated Artifacts and Work Products");
|
|
expect(referenceBody).toContain("scripts/paperclip-upload-artifact.sh");
|
|
expect(referenceBody).toContain("POST");
|
|
expect(referenceBody).toContain("/api/companies/$PAPERCLIP_COMPANY_ID/issues/$PAPERCLIP_TASK_ID/attachments");
|
|
expect(referenceBody).toContain("/api/issues/$PAPERCLIP_TASK_ID/work-products");
|
|
await expect(
|
|
fs.access(path.resolve("skills/paperclip/scripts/paperclip-upload-artifact.sh")),
|
|
).resolves.toBeUndefined();
|
|
await expect(fs.access(path.resolve("scripts/paperclip-upload-artifact.sh"))).rejects.toThrow();
|
|
});
|
|
|
|
it("keeps the create-issue-interaction-ui guide as a maintainer-only skill", async () => {
|
|
const skillPath = path.resolve(".agents/skills/create-issue-interaction-ui/SKILL.md");
|
|
const skillBody = await fs.readFile(skillPath, "utf8");
|
|
const normalizedSkillBody = skillBody.replace(/\s+/g, " ");
|
|
const normalizedLowerSkillBody = normalizedSkillBody.toLowerCase();
|
|
|
|
expect(skillBody).toContain("name: create-issue-interaction-ui");
|
|
expect(normalizedLowerSkillBody).toContain("developer/maintainer skill");
|
|
expect(normalizedLowerSkillBody).toContain(
|
|
"not the operational agents that run inside a deployed paperclip company",
|
|
);
|
|
expect(skillBody).toContain("packages/shared/src/constants.ts");
|
|
expect(skillBody).toContain("server/src/services/issue-thread-interactions.ts");
|
|
expect(skillBody).toContain("ui/src/components/IssueThreadInteractionCard.tsx");
|
|
expect(skillBody).toContain("packages/plugins/sdk/src/testing.ts");
|
|
await expect(fs.access(path.resolve("skills/create-issue-interaction-ui/SKILL.md"))).rejects.toThrow();
|
|
});
|
|
|
|
it("removes stale maintainer-only symlinks from a shared skills home", async () => {
|
|
const root = await makeTempDir("paperclip-skill-cleanup-");
|
|
cleanupDirs.add(root);
|
|
|
|
const skillsHome = path.join(root, "skills-home");
|
|
const runtimeSkill = path.join(root, "skills", "paperclip");
|
|
const customSkill = path.join(root, "custom", "release-notes");
|
|
const staleMaintainerSkill = path.join(root, ".agents", "skills", "release");
|
|
|
|
await fs.mkdir(skillsHome, { recursive: true });
|
|
await fs.mkdir(runtimeSkill, { recursive: true });
|
|
await fs.mkdir(customSkill, { recursive: true });
|
|
|
|
await fs.symlink(runtimeSkill, path.join(skillsHome, "paperclip"));
|
|
await fs.symlink(customSkill, path.join(skillsHome, "release-notes"));
|
|
await fs.symlink(staleMaintainerSkill, path.join(skillsHome, "release"));
|
|
|
|
const removed = await removeMaintainerOnlySkillSymlinks(skillsHome, ["paperclip"]);
|
|
|
|
expect(removed).toEqual(["release"]);
|
|
await expect(fs.lstat(path.join(skillsHome, "release"))).rejects.toThrow();
|
|
expect((await fs.lstat(path.join(skillsHome, "paperclip"))).isSymbolicLink()).toBe(true);
|
|
expect((await fs.lstat(path.join(skillsHome, "release-notes"))).isSymbolicLink()).toBe(true);
|
|
});
|
|
});
|