Merge remote-tracking branch 'origin/master' into PAPA-1235-fix-ci

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-07-10 10:09:13 -07:00
commit db75bc48ed
3 files changed
+98 -30

No files matched your search

@@ -68,10 +68,13 @@ describe("paperclip skill utils", () => {
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(skillBody).toContain("Developer/maintainer skill");
expect(normalizedSkillBody).toContain("Do NOT install this on production Paperclip agents");
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");
+65 -13
View File
@@ -794,6 +794,7 @@ describe("IssueChatThread", () => {
onAdd={async () => {}}
showComposer={false}
showJumpToLatest={false}
autoScrollToHashOnInitialLoad
enableLiveTranscriptPolling={false}
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
@@ -891,7 +892,7 @@ describe("IssueChatThread", () => {
requestAnimationFrameMock.mockRestore();
});
it("scrolls loaded hash targets through the virtualized message index", () => {
it("scrolls loaded hash targets through the virtualized message index when initial hash scrolling is enabled", () => {
const root = createRoot(container);
const targetComment = issueChatLongThreadComments.at(-1);
expect(targetComment).toBeDefined();
@@ -910,6 +911,7 @@ describe("IssueChatThread", () => {
onAdd={async () => {}}
showComposer={false}
showJumpToLatest={false}
autoScrollToHashOnInitialLoad
enableLiveTranscriptPolling={false}
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
@@ -1071,8 +1073,8 @@ describe("IssueChatThread", () => {
) as HTMLButtonElement | undefined;
expect(jump).toBeDefined();
// Flush the on-load auto-scroll-to-latest (PAP-97) so this test measures
// only the jump-to-latest interaction, not the initial mount scroll.
// Flush pending mount timers so this test measures only the explicit
// jump-to-latest interaction.
act(() => {
vi.advanceTimersByTime(500);
});
@@ -1101,10 +1103,9 @@ describe("IssueChatThread", () => {
scrollHost.remove();
});
// PAP-97: on first thread load we land on the latest comment instead of the
// top of the thread (board rev-2 feedback for PAP-95). No deep-link hash and
// no user interaction — the scroll must happen purely from mounting.
it("auto-scrolls to the latest comment on initial load (PAP-97)", () => {
// PAP-12003: initial page load must not jump to the latest comment. If a
// user wants the newest message, the explicit Jump to latest control owns it.
it("does not auto-scroll to the latest comment on initial load", () => {
vi.useFakeTimers();
container.remove();
const scrollHost = document.createElement("main");
@@ -1142,15 +1143,13 @@ describe("IssueChatThread", () => {
);
});
// No jump click — let the mount auto-scroll's rAF + settle ticks run.
// No jump click: initial render should preserve the page position.
act(() => {
vi.advanceTimersByTime(500);
});
const scrolledToLatest =
elementScrollToMock.mock.calls.some(([arg]) => hasSmoothScrollBehavior(arg))
|| scrollIntoViewMock.mock.calls.length > 0;
expect(scrolledToLatest).toBe(true);
expect(elementScrollToMock).not.toHaveBeenCalled();
expect(scrollIntoViewMock).not.toHaveBeenCalled();
Element.prototype.scrollIntoView = originalScrollIntoView;
act(() => {
@@ -1190,7 +1189,6 @@ describe("IssueChatThread", () => {
agentMap={issueChatLongThreadAgentMap}
currentUserId="user-board"
onAdd={async () => {}}
autoScrollToLatestOnInitialLoad={false}
enableLiveTranscriptPolling={false}
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
@@ -1228,6 +1226,60 @@ describe("IssueChatThread", () => {
vi.useRealTimers();
});
it("can keep the page at the top on initial load even when the URL has a comment hash", () => {
vi.useFakeTimers();
const originalScrollIntoView = Element.prototype.scrollIntoView;
const scrollIntoViewMock = vi.fn();
Object.defineProperty(Element.prototype, "scrollIntoView", {
configurable: true,
value: scrollIntoViewMock,
});
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter initialEntries={["/PAP/issues/PAP-12003#comment-comment-target"]}>
<IssueChatThread
comments={[{
id: "comment-target",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: "agent-1",
authorUserId: null,
authorType: "agent",
body: "Previous done comment near the bottom.",
presentation: null,
metadata: null,
createdAt: new Date("2026-07-07T21:13:07.902Z"),
updatedAt: new Date("2026-07-07T21:13:07.902Z"),
}]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
act(() => {
vi.advanceTimersByTime(500);
});
expect(scrollIntoViewMock).not.toHaveBeenCalled();
Object.defineProperty(Element.prototype, "scrollIntoView", {
configurable: true,
value: originalScrollIntoView,
});
act(() => {
root.unmount();
});
vi.useRealTimers();
});
// Regression for PAP-2672: when the merged feed ends with a non-comment row
// (run/timeline/embedded output) we still want Jump to latest to land on the
// last comment, not whichever activity row sorts last.
+28 -15
View File
@@ -481,6 +481,7 @@ interface IssueChatThreadProps {
showComposer?: boolean;
showJumpToLatest?: boolean;
autoScrollToLatestOnInitialLoad?: boolean;
autoScrollToHashOnInitialLoad?: boolean;
emptyMessage?: string;
footer?: ReactNode;
variant?: "full" | "embedded";
@@ -4216,7 +4217,8 @@ export function IssueChatThread({
composerHint = null,
showComposer = true,
showJumpToLatest,
autoScrollToLatestOnInitialLoad = true,
autoScrollToLatestOnInitialLoad = false,
autoScrollToHashOnInitialLoad = false,
emptyMessage,
footer,
variant = "full",
@@ -4246,6 +4248,7 @@ export function IssueChatThread({
}: IssueChatThreadProps) {
const location = useLocation();
const lastScrolledHashRef = useRef<string | null>(null);
const didInitialHashScrollDecisionRef = useRef(false);
const virtualizedThreadRef = useRef<VirtualizedIssueChatThreadListHandle | null>(null);
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
const composerViewportAnchorRef = useRef<HTMLDivElement | null>(null);
@@ -4511,19 +4514,23 @@ export function IssueChatThread({
useEffect(() => {
const hash = location.hash || (typeof window !== "undefined" ? window.location.hash : "");
if (
!(
hash.startsWith("#comment-")
|| hash.startsWith("#activity-")
|| hash.startsWith("#run-")
|| hash.startsWith("#interaction-")
)
) return;
if (messages.length === 0 || lastScrolledHashRef.current === hash) return;
const isThreadHash = hash.startsWith("#comment-")
|| hash.startsWith("#activity-")
|| hash.startsWith("#run-")
|| hash.startsWith("#interaction-");
if (messages.length === 0) return;
if (!isThreadHash) {
if (!didInitialHashScrollDecisionRef.current) {
didInitialHashScrollDecisionRef.current = true;
}
return;
}
if (lastScrolledHashRef.current === hash) return;
const targetId = hash.slice(1);
if (targetId.startsWith("comment-")) {
const targetMessage = messages.find((message) => issueChatMessageAnchorId(message) === targetId);
if (targetMessage && issueChatMessageIsDeleted(targetMessage)) {
didInitialHashScrollDecisionRef.current = true;
lastScrolledHashRef.current = hash;
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `${location.pathname}${location.search}`);
@@ -4531,6 +4538,13 @@ export function IssueChatThread({
return;
}
}
if (!didInitialHashScrollDecisionRef.current) {
didInitialHashScrollDecisionRef.current = true;
if (!autoScrollToHashOnInitialLoad) {
lastScrolledHashRef.current = hash;
return;
}
}
let cancelled = false;
const attemptScroll = (finalAttempt = false) => {
if (cancelled || lastScrolledHashRef.current === hash) return;
@@ -4549,12 +4563,11 @@ export function IssueChatThread({
cancelAnimationFrame(frame);
window.clearTimeout(timeout);
};
}, [location.hash, messageAnchorIndex, messages, useVirtualizedThread]);
}, [autoScrollToHashOnInitialLoad, location.hash, messageAnchorIndex, messages, useVirtualizedThread]);
// On first thread load, land on the latest comment instead of defaulting to
// the top (board rev-2 feedback for PAP-95). A deep-link hash takes
// precedence — the hash-scroll effect above owns that case. Runs once per
// mount, after messages first populate.
// Optional legacy behavior: callers may explicitly request landing on the
// latest comment. The shared default stays off so ordinary page loads keep
// the user's initial viewport stable.
useEffect(() => {
if (didInitialLatestScrollRef.current) return;
if (!autoScrollToLatestOnInitialLoad) return;