diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 5b9bb641..ab6a4232 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -671,7 +671,6 @@ export function issueRoutes( details: { issueId: issue.id, status: issue.status, - securityPrinciples: ["Complete Mediation", "Fail Securely"], }, }); return false; @@ -694,7 +693,6 @@ export function issueRoutes( holdId: activePauseHold.holdId, rootIssueId: activePauseHold.rootIssueId, mode: activePauseHold.mode, - securityPrinciples: ["Complete Mediation", "Fail Securely", "Secure Defaults"], }, }); return false; @@ -928,7 +926,6 @@ export function issueRoutes( const parsedOffset = rawOffset !== undefined && /^\d+$/.test(rawOffset) ? Number.parseInt(rawOffset, 10) : null; - const offset = parsedOffset ?? 0; if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); @@ -954,6 +951,7 @@ export function issueRoutes( res.status(400).json({ error: "offset must be a non-negative integer" }); return; } + const offset = parsedOffset ?? 0; const result = await svc.list(companyId, { status: req.query.status as string | undefined, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 741d8ce3..104d12da 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -942,7 +942,8 @@ async function listIssueProductivityReviewMap( isNull(issues.hiddenAt), notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES), ), - ); + ) + .orderBy(desc(issues.createdAt), desc(issues.id)); reviewRows.push(...rows); } @@ -985,6 +986,7 @@ async function listIssueProductivityReviewMap( for (const row of reviewRows) { if (!row.sourceIssueId) continue; + if (map.has(row.sourceIssueId)) continue; const detail = triggerByReviewIssueId.get(row.reviewIssueId); map.set(row.sourceIssueId, { reviewIssueId: row.reviewIssueId, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c34d8d96..ec82de9d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -14,6 +14,7 @@ import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail"; import { Workspaces } from "./pages/Workspaces"; import { Issues } from "./pages/Issues"; import { IssueDetail } from "./pages/IssueDetail"; +import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf"; import { Routines } from "./pages/Routines"; import { RoutineDetail } from "./pages/RoutineDetail"; import { UserProfile } from "./pages/UserProfile"; @@ -50,7 +51,7 @@ import { InviteLandingPage } from "./pages/InviteLanding"; import { JoinRequestQueue } from "./pages/JoinRequestQueue"; import { NotFoundPage } from "./pages/NotFound"; import { useCompany } from "./context/CompanyContext"; -import { useDialog } from "./context/DialogContext"; +import { useDialogActions } from "./context/DialogContext"; import { loadLastInboxTab } from "./lib/inbox"; import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route"; @@ -98,6 +99,9 @@ function boardRoutes() { } /> } /> } /> + {import.meta.env.DEV ? ( + } /> + ) : null} } /> } /> } /> @@ -139,7 +143,7 @@ function LegacySettingsRedirect() { function OnboardingRoutePage() { const { companies } = useCompany(); - const { openOnboarding } = useDialog(); + const { openOnboarding } = useDialogActions(); const { companyPrefix } = useParams<{ companyPrefix?: string }>(); const matchedCompany = companyPrefix ? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null @@ -231,7 +235,7 @@ function UnprefixedBoardRedirect() { } function NoCompaniesStartPage() { - const { openOnboarding } = useDialog(); + const { openOnboarding } = useDialogActions(); return (
diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 6dba7027..107f12b1 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -39,4 +39,12 @@ describe("issuesApi.list", () => { "/companies/company-1/issues?workspaceId=workspace-1&limit=1000", ); }); + + it("passes pagination offsets through to the company issues endpoint", async () => { + await issuesApi.list("company-1", { limit: 500, offset: 1500 }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/issues?limit=500&offset=1500", + ); + }); }); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 53fb9b92..61abcba6 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -48,6 +48,7 @@ export const issuesApi = { includeBlockedBy?: boolean; q?: string; limit?: number; + offset?: number; }, ) => { const params = new URLSearchParams(); @@ -70,6 +71,7 @@ export const issuesApi = { if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true"); if (filters?.q) params.set("q", filters.q); if (filters?.limit) params.set("limit", String(filters.limit)); + if (filters?.offset !== undefined) params.set("offset", String(filters.offset)); const qs = params.toString(); return api.get(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`); }, diff --git a/ui/src/components/CommandPalette.test.tsx b/ui/src/components/CommandPalette.test.tsx index b229cc96..165f1390 100644 --- a/ui/src/components/CommandPalette.test.tsx +++ b/ui/src/components/CommandPalette.test.tsx @@ -39,6 +39,7 @@ vi.mock("../context/CompanyContext", () => ({ vi.mock("../context/DialogContext", () => ({ useDialog: () => dialogState, + useDialogActions: () => dialogState, })); vi.mock("../context/SidebarContext", () => ({ diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index f5a0ef75..28530042 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from "react"; import { useNavigate } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { useCompany } from "../context/CompanyContext"; -import { useDialog } from "../context/DialogContext"; +import { useDialogActions } from "../context/DialogContext"; import { useSidebar } from "../context/SidebarContext"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; @@ -37,7 +37,7 @@ export function CommandPalette() { const [query, setQuery] = useState(""); const navigate = useNavigate(); const { selectedCompanyId } = useCompany(); - const { openNewIssue, openNewAgent } = useDialog(); + const { openNewIssue, openNewAgent } = useDialogActions(); const { isMobile, setSidebarOpen } = useSidebar(); const searchQuery = query.trim(); diff --git a/ui/src/components/CompanyRail.tsx b/ui/src/components/CompanyRail.tsx index 496dd40a..2766a16b 100644 --- a/ui/src/components/CompanyRail.tsx +++ b/ui/src/components/CompanyRail.tsx @@ -17,7 +17,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useCompany } from "../context/CompanyContext"; -import { useDialog } from "../context/DialogContext"; +import { useDialogActions } from "../context/DialogContext"; import { cn } from "../lib/utils"; import { queryKeys } from "../lib/queryKeys"; import { sidebarBadgesApi } from "../api/sidebarBadges"; @@ -125,7 +125,7 @@ function SortableCompanyItem({ export function CompanyRail() { const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); - const { openOnboarding } = useDialog(); + const { openOnboarding } = useDialogActions(); const navigate = useNavigate(); const location = useLocation(); const isInstanceRoute = location.pathname.startsWith("/instance/"); diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 5ec92b11..6837fe0d 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -8,17 +8,40 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Agent } from "@paperclipai/shared"; import { IssueChatThread, + VIRTUALIZED_THREAD_ROW_THRESHOLD, canStopIssueChatRun, + findLatestCommentMessageIndex, resolveAssistantMessageFoldedState, resolveIssueChatHumanAuthor, } from "./IssueChatThread"; +import { ToastProvider } from "../context/ToastContext"; +import { ToastViewport } from "./ToastViewport"; import type { AskUserQuestionsInteraction, RequestConfirmationInteraction, SuggestTasksInteraction, } from "../lib/issue-thread-interactions"; +import { + issueChatLongThreadAgentMap, + issueChatLongThreadComments, + issueChatLongThreadEvents, + issueChatLongThreadLinkedRuns, + issueChatLongThreadTranscriptsByRunId, +} from "../fixtures/issueChatLongThreadFixture"; +import type { + IssueChatLinkedRun, + IssueChatTranscriptEntry, +} from "../lib/issue-chat-messages"; -const { markdownEditorFocusMock } = vi.hoisted(() => ({ +function hasSmoothScrollBehavior(arg: unknown) { + return typeof arg === "object" + && arg !== null + && "behavior" in arg + && (arg as ScrollToOptions).behavior === "smooth"; +} + +const { markdownBodyRenderMock, markdownEditorFocusMock } = vi.hoisted(() => ({ + markdownBodyRenderMock: vi.fn(), markdownEditorFocusMock: vi.fn(), })); @@ -59,7 +82,10 @@ vi.mock("../lib/issue-chat-scroll", async (importOriginal) => { }); vi.mock("./MarkdownBody", () => ({ - MarkdownBody: ({ children }: { children: ReactNode }) =>
{children}
, + MarkdownBody: ({ children }: { children: ReactNode }) => { + markdownBodyRenderMock(children); + return
{children}
; + }, })); vi.mock("./MarkdownEditor", () => ({ @@ -273,6 +299,7 @@ describe("IssueChatThread", () => { beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); + window.scrollTo = vi.fn(); localStorage.clear(); }); @@ -284,6 +311,7 @@ describe("IssueChatThread", () => { captureComposerViewportSnapshotMock.mockClear(); restoreComposerViewportSnapshotMock.mockClear(); shouldPreserveComposerViewportMock.mockClear(); + markdownBodyRenderMock.mockClear(); }); it("drops the count heading and does not use an internal scrollbox", () => { @@ -318,6 +346,644 @@ describe("IssueChatThread", () => { }); }); + it("virtualizes long merged threads so only a windowed slice mounts", () => { + const root = createRoot(container); + const totalMergedRows = + issueChatLongThreadComments.length + + issueChatLongThreadEvents.length + + issueChatLongThreadLinkedRuns.length; + expect(totalMergedRows).toBeGreaterThanOrEqual(VIRTUALIZED_THREAD_ROW_THRESHOLD); + + act(() => { + root.render( + + {}} + showComposer={false} + showJumpToLatest={false} + enableLiveTranscriptPolling={false} + transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} + hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)} + /> + , + ); + }); + + const virtualizer = container.querySelector( + '[data-testid="issue-chat-thread-virtualizer"]', + ) as HTMLDivElement | null; + expect(virtualizer).not.toBeNull(); + expect(virtualizer?.dataset.virtualCount).toBe(String(totalMergedRows)); + + const rows = container.querySelectorAll('[data-testid="issue-chat-message-row"]'); + expect(rows.length).toBeGreaterThan(0); + expect(rows.length).toBeLessThan(totalMergedRows); + + const virtualRows = container.querySelectorAll( + '[data-testid="issue-chat-thread-virtual-row"]', + ); + expect(virtualRows.length).toBe(rows.length); + for (const row of Array.from(virtualRows)) { + const transform = (row as HTMLDivElement).style.transform; + expect(transform).toMatch(/translateY\(/); + } + + act(() => { + root.unmount(); + }); + }); + + it("measures tall virtual rows before positioning following rows", async () => { + const root = createRoot(container); + const requestAnimationFrameMock = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback) => { + callback(0); + return 0; + }); + + act(() => { + root.render( + + {}} + showComposer={false} + showJumpToLatest={false} + enableLiveTranscriptPolling={false} + transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} + hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)} + /> + , + ); + }); + + const virtualRows = container.querySelectorAll( + '[data-testid="issue-chat-thread-virtual-row"]', + ); + expect(virtualRows.length).toBeGreaterThan(1); + + Object.defineProperty(virtualRows[0], "getBoundingClientRect", { + configurable: true, + value: () => ({ + x: 0, + y: 0, + width: 700, + height: 800, + top: 0, + right: 700, + bottom: 800, + left: 0, + toJSON: () => ({}), + }), + }); + + await act(async () => { + virtualRows[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + const nextTransform = virtualRows[1].style.transform; + const translateY = Number(nextTransform.match(/translateY\(([-\d.]+)px\)/)?.[1] ?? "0"); + expect(translateY).toBeGreaterThanOrEqual(800); + + act(() => { + root.unmount(); + }); + requestAnimationFrameMock.mockRestore(); + }); + + it("scrolls loaded hash targets through the virtualized message index", () => { + const root = createRoot(container); + const targetComment = issueChatLongThreadComments.at(-1); + expect(targetComment).toBeDefined(); + const scrollToMock = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + + act(() => { + root.render( + + {}} + showComposer={false} + showJumpToLatest={false} + enableLiveTranscriptPolling={false} + transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} + hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)} + /> + , + ); + }); + + expect(scrollToMock.mock.calls.some(([arg]) => hasSmoothScrollBehavior(arg))).toBe(true); + + scrollToMock.mockRestore(); + act(() => { + root.unmount(); + }); + }); + + it("uses the virtualizer when jumping to the latest long-thread row", () => { + const root = createRoot(container); + const scrollToMock = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} + hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)} + /> + , + ); + }); + + const jump = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Jump to latest", + ) as HTMLButtonElement | undefined; + expect(jump).toBeDefined(); + + act(() => { + jump?.click(); + }); + + expect(scrollToMock.mock.calls.some(([arg]) => hasSmoothScrollBehavior(arg))).toBe(true); + + scrollToMock.mockRestore(); + act(() => { + root.unmount(); + }); + }); + + // Regression for PAP-2660: on the real issue page the chat thread is wrapped + // in `
`, so the virtualizer must bind + // to that ancestor's scroll instead of `window` (which never moves on + // desktop). When mounted inside an overflow-auto ancestor the jump-to-latest + // action must drive that element's scrollTo, not window.scrollTo. + it("targets an overflow-auto ancestor instead of window scroll on jump-to-latest", () => { + container.remove(); + const scrollHost = document.createElement("main"); + scrollHost.id = "main-content"; + scrollHost.style.overflowY = "auto"; + scrollHost.style.overflow = "auto"; + scrollHost.style.height = "640px"; + document.body.appendChild(scrollHost); + container = document.createElement("div"); + scrollHost.appendChild(container); + + const root = createRoot(container); + const windowScrollToMock = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + const elementScrollToMock = vi.fn(); + scrollHost.scrollTo = elementScrollToMock as unknown as typeof scrollHost.scrollTo; + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} + hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)} + /> + , + ); + }); + + const jump = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Jump to latest", + ) as HTMLButtonElement | undefined; + expect(jump).toBeDefined(); + + windowScrollToMock.mockClear(); + elementScrollToMock.mockClear(); + + act(() => { + jump?.click(); + }); + + expect(elementScrollToMock.mock.calls.some(([arg]) => hasSmoothScrollBehavior(arg))).toBe(true); + expect(windowScrollToMock.mock.calls.some(([arg]) => hasSmoothScrollBehavior(arg))).toBe(false); + + windowScrollToMock.mockRestore(); + act(() => { + root.unmount(); + }); + scrollHost.remove(); + }); + + // 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. + it("targets the latest comment row when trailing rows are non-comments (PAP-2672)", () => { + const lastComment = issueChatLongThreadComments.at(-1); + expect(lastComment).toBeDefined(); + const trailingRunStart = new Date(new Date(lastComment!.createdAt).getTime() + 60_000); + const trailingRun: IssueChatLinkedRun = { + runId: "trailing-run-pap-2672", + status: "failed", + agentId: "agent-perf-codex", + agentName: "TrailingRunner", + adapterType: "codex_local", + createdAt: trailingRunStart, + startedAt: trailingRunStart, + finishedAt: trailingRunStart, + hasStoredOutput: true, + }; + const trailingTranscriptEntries: readonly IssueChatTranscriptEntry[] = [ + { + kind: "assistant", + ts: trailingRunStart.toISOString(), + text: "Trailing run posted after the latest comment.", + }, + ]; + const transcriptsByRunId = new Map(issueChatLongThreadTranscriptsByRunId); + transcriptsByRunId.set(trailingRun.runId, trailingTranscriptEntries); + const linkedRuns: IssueChatLinkedRun[] = [ + ...issueChatLongThreadLinkedRuns, + trailingRun, + ]; + + container.remove(); + const scrollHost = document.createElement("main"); + scrollHost.id = "main-content"; + scrollHost.style.overflowY = "auto"; + scrollHost.style.overflow = "auto"; + scrollHost.style.height = "800px"; + Object.defineProperty(scrollHost, "scrollHeight", { + configurable: true, + get: () => 200_000, + }); + Object.defineProperty(scrollHost, "clientHeight", { + configurable: true, + get: () => 800, + }); + document.body.appendChild(scrollHost); + container = document.createElement("div"); + scrollHost.appendChild(container); + + const elementScrollToMock = vi.fn(); + scrollHost.scrollTo = elementScrollToMock as unknown as typeof scrollHost.scrollTo; + + const root = createRoot(container); + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + transcriptsByRunId={transcriptsByRunId} + hasOutputForRun={(runId) => transcriptsByRunId.has(runId)} + /> + , + ); + }); + + const virtualizerEl = container.querySelector( + '[data-testid="issue-chat-thread-virtualizer"]', + ); + expect(virtualizerEl).not.toBeNull(); + const totalMergedRows = Number(virtualizerEl?.dataset.virtualCount ?? "0"); + expect(totalMergedRows).toBeGreaterThan(VIRTUALIZED_THREAD_ROW_THRESHOLD); + + elementScrollToMock.mockClear(); + + const jump = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Jump to latest", + ) as HTMLButtonElement | undefined; + expect(jump).toBeDefined(); + + act(() => { + jump?.click(); + }); + + const smoothCalls = elementScrollToMock.mock.calls + .map((call) => call[0] as ScrollToOptions) + .filter(hasSmoothScrollBehavior); + expect(smoothCalls.length).toBeGreaterThan(0); + + // For align="end" with the very last index, tanstack-virtual short-circuits + // to getMaxScrollOffset() (= scrollHeight - clientHeight = 199_200 here). + // A jump to the latest comment row (one slot earlier) lands at item.end - + // clientHeight, which is strictly less. Asserting top < maxScrollOffset + // proves the button isn't routing to the trailing run row. + const maxScrollOffset = 200_000 - 800; + const lastTop = smoothCalls[smoothCalls.length - 1]?.top; + expect(typeof lastTop).toBe("number"); + expect(lastTop as number).toBeLessThan(maxScrollOffset); + expect(lastTop as number).toBeGreaterThan(0); + + act(() => { + root.unmount(); + }); + scrollHost.remove(); + }); + + // Regression for PAP-2672 follow-up: clicking Jump to latest must refresh + // the comments page so a comment that arrived after the initial load is + // present before we scroll. Otherwise the user lands on the latest *loaded* + // comment but not the absolute newest. + it("invokes onRefreshLatestComments before scrolling on Jump to latest", async () => { + const refreshMock = vi.fn(async () => undefined); + const directComments = issueChatLongThreadComments.slice(0, 8); + + const root = createRoot(container); + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + onRefreshLatestComments={refreshMock} + /> + , + ); + }); + + const jump = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Jump to latest", + ) as HTMLButtonElement | undefined; + expect(jump).toBeDefined(); + + act(() => { + jump?.click(); + }); + + expect(refreshMock).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); + + it("findLatestCommentMessageIndex prefers the last comment-anchored row (PAP-2672)", () => { + const messages = [ + { metadata: { custom: { anchorId: "comment-a" } } }, + { metadata: { custom: { anchorId: "run-1" } } }, + { metadata: { custom: { anchorId: "comment-b" } } }, + { metadata: { custom: { anchorId: "run-2" } } }, + { metadata: { custom: { anchorId: "activity-3" } } }, + ]; + expect(findLatestCommentMessageIndex(messages as never)).toBe(2); + expect( + findLatestCommentMessageIndex([ + { metadata: { custom: { anchorId: "run-only" } } }, + ] as never), + ).toBe(-1); + expect(findLatestCommentMessageIndex([] as never)).toBe(-1); + }); + + it("keeps the direct render path for short threads under the virtualization threshold", () => { + const root = createRoot(container); + const directComments = issueChatLongThreadComments.slice(0, 12); + + act(() => { + root.render( + + {}} + showComposer={false} + showJumpToLatest={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect( + container.querySelector('[data-testid="issue-chat-thread-virtualizer"]'), + ).toBeNull(); + const rows = container.querySelectorAll('[data-testid="issue-chat-message-row"]'); + expect(rows.length).toBe(directComments.length); + + act(() => { + root.unmount(); + }); + }); + + it("renders virtualized rows with the same role/kind metadata as the direct path", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + showComposer={false} + showJumpToLatest={false} + enableLiveTranscriptPolling={false} + transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} + hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)} + /> + , + ); + }); + + const rows = container.querySelectorAll('[data-testid="issue-chat-message-row"]'); + expect(rows.length).toBeGreaterThan(0); + const roles = new Set(); + const kinds = new Set(); + for (const row of Array.from(rows)) { + const element = row as HTMLDivElement; + const role = element.dataset.messageRole; + const kind = element.dataset.messageKind; + if (role) roles.add(role); + if (kind) kinds.add(kind); + } + expect(roles.size).toBeGreaterThan(0); + expect(kinds.size).toBeGreaterThan(0); + + act(() => { + root.unmount(); + }); + }); + + it("does not re-render long-thread markdown rows for unrelated layout updates", () => { + const root = createRoot(container); + const onAdd = async () => {}; + const hasOutputForRun = (runId: string) => issueChatLongThreadTranscriptsByRunId.has(runId); + + act(() => { + root.render( + + + , + ); + }); + + expect(markdownBodyRenderMock).toHaveBeenCalled(); + markdownBodyRenderMock.mockClear(); + + act(() => { + root.render( + + + , + ); + }); + + expect(markdownBodyRenderMock).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + }); + + it("does not re-render unchanged markdown when feedback votes change", () => { + const root = createRoot(container); + const onAdd = async () => {}; + const onVote = async () => {}; + const comments = [{ + id: "comment-agent-feedback", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: "agent-1", + authorUserId: null, + body: "Agent summary with **markdown**", + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }]; + + act(() => { + root.render( + + + , + ); + }); + + expect(markdownBodyRenderMock).toHaveBeenCalled(); + markdownBodyRenderMock.mockClear(); + + act(() => { + root.render( + + + , + ); + }); + + expect(markdownBodyRenderMock).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + }); + it("shows explicit follow-up badges and event copy", () => { const root = createRoot(container); @@ -1258,6 +1924,127 @@ describe("IssueChatThread", () => { }); }); + it("warns once before sending a reply with no assignee selected", async () => { + const root = createRoot(container); + + act(() => { + root.render( + + + + {}} + enableReassign + reassignOptions={[ + { id: "", label: "No assignee" }, + { id: "agent:agent-1", label: "Agent 1" }, + ]} + currentAssigneeValue="" + suggestedAssigneeValue="" + enableLiveTranscriptPolling={false} + /> + + , + ); + }); + + const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null; + const submitButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent === "Send", + ) as HTMLButtonElement | undefined; + expect(editor).not.toBeNull(); + expect(submitButton).toBeDefined(); + + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value", + )?.set; + valueSetter?.call(editor, "Reply without assignee"); + editor?.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + submitButton?.click(); + }); + + expect(appendMock).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("No assignee selected"); + + await act(async () => { + submitButton?.click(); + }); + + expect(appendMock).toHaveBeenCalledTimes(1); + expect(appendMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: [{ type: "text", text: "Reply without assignee" }], + }), + ); + + act(() => { + root.unmount(); + }); + }); + + it("does not warn when sending a reply with an assignee selected", async () => { + const root = createRoot(container); + + act(() => { + root.render( + + + + {}} + enableReassign + reassignOptions={[ + { id: "", label: "No assignee" }, + { id: "agent:agent-1", label: "Agent 1" }, + ]} + currentAssigneeValue="agent:agent-1" + suggestedAssigneeValue="agent:agent-1" + enableLiveTranscriptPolling={false} + /> + + , + ); + }); + + const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null; + const submitButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent === "Send", + ) as HTMLButtonElement | undefined; + + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value", + )?.set; + valueSetter?.call(editor, "Reply with assignee"); + editor?.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + submitButton?.click(); + }); + + expect(appendMock).toHaveBeenCalledTimes(1); + expect(document.body.textContent).not.toContain("No assignee selected"); + + act(() => { + root.unmount(); + }); + }); + it("exposes a composer focus handle that forwards to the editor", () => { const root = createRoot(container); const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>(); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index bc9dc2cf..6a89c073 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -12,6 +12,8 @@ import { createContext, Component, forwardRef, + memo, + useCallback, useContext, useEffect, useImperativeHandle, @@ -88,6 +90,7 @@ import { shouldPreserveComposerViewport, } from "../lib/issue-chat-scroll"; import { formatAssigneeUserLabel } from "../lib/assignees"; +import { useOptionalToastActions } from "../context/ToastContext"; import type { CompanyUserProfile } from "../lib/company-members"; import { timeAgo } from "../lib/timeAgo"; import { @@ -107,24 +110,20 @@ import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loa import { IssueBlockedNotice } from "./IssueBlockedNotice"; interface IssueChatMessageContext { - feedbackVoteByTargetId: Map; feedbackDataSharingPreference: FeedbackDataSharingPreference; feedbackTermsUrl: string | null; agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; userProfileMap?: ReadonlyMap | null; - activeRunIds: ReadonlySet; onVote?: ( commentId: string, vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => Promise; onStopRun?: (runId: string) => Promise; - stoppingRunId?: string | null; onInterruptQueued?: (runId: string) => Promise; onCancelQueued?: (commentId: string) => void; - interruptingQueuedRunId?: string | null; onImageClick?: (src: string) => void; onAcceptInteraction?: ( interaction: SuggestTasksInteraction | RequestConfirmationInteraction, @@ -141,10 +140,8 @@ interface IssueChatMessageContext { } const IssueChatCtx = createContext({ - feedbackVoteByTargetId: new Map(), feedbackDataSharingPreference: "prompt", feedbackTermsUrl: null, - activeRunIds: new Set(), }); export function resolveAssistantMessageFoldedState(args: { @@ -209,6 +206,21 @@ function useLiveElapsed(startMs: number | null | undefined, active: boolean): st return formatDurationWords(Date.now() - startMs); } +function useStableEvent unknown>(callback: T | undefined): T | undefined { + const callbackRef = useRef(callback); + useLayoutEffect(() => { + callbackRef.current = callback; + }, [callback]); + + return useMemo(() => { + if (!callback) return undefined; + return ((...args: Parameters) => callbackRef.current?.(...args)) as T; + // Keep the wrapper stable while the callback identity changes; the ref above + // carries the current callback implementation. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [Boolean(callback)]); +} + interface CommentReassignment { assigneeAgentId: string | null; assigneeUserId: string | null; @@ -297,6 +309,12 @@ interface IssueChatThreadProps { answers: AskUserQuestionsAnswer[], ) => Promise | void; composerRef?: Ref; + /** + * Hook for the parent to refetch comments when the user explicitly asks + * to jump to the latest comment. Used to make sure the absolute newest + * comment is in the loaded set before we scroll to it. + */ + onRefreshLatestComments?: () => Promise | void; } type IssueChatErrorBoundaryProps = { @@ -538,6 +556,10 @@ function shouldImplicitlyReopenComment(issueStatus: string | undefined, assignee return resumesToTodo && assigneeValue.startsWith("agent:"); } +function isUnassignedReassignValue(value: string): boolean { + return !value || value === "__none__"; +} + const WEEK_MS = 7 * 24 * 60 * 60 * 1000; function commentDateLabel(date: Date | string | undefined): string { @@ -547,7 +569,7 @@ function commentDateLabel(date: Date | string | undefined): string { return formatShortDate(date); } -function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) { +const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) { const { onImageClick } = useContext(IssueChatCtx); return ( ); -} +}); function humanizeValue(value: string | null) { if (!value) return "None"; @@ -1047,7 +1069,7 @@ function getThreadMessageCopyText(message: ThreadMessage) { .join("\n\n"); } -function IssueChatTextParts({ +const IssueChatTextParts = memo(function IssueChatTextParts({ message, recessed = false, }: { @@ -1067,7 +1089,7 @@ function IssueChatTextParts({ ))} ); -} +}); function groupAssistantParts( content: readonly ThreadMessage["content"][number][], @@ -1105,16 +1127,17 @@ function groupAssistantParts( return groups; } -function IssueChatAssistantParts({ +const IssueChatAssistantParts = memo(function IssueChatAssistantParts({ message, hasCoT, }: { message: ThreadMessage; hasCoT: boolean; }) { + const groupedParts = useMemo(() => groupAssistantParts(message.content), [message.content]); return ( <> - {groupAssistantParts(message.content).map((group) => { + {groupedParts.map((group) => { if (group.type === "text") { return ( ); -} +}); -function IssueChatUserMessage({ message }: { message: ThreadMessage }) { +function IssueChatUserMessage({ + message, + isInterruptingQueuedRun, +}: { + message: ThreadMessage; + isInterruptingQueuedRun: boolean; +}) { const { onInterruptQueued, onCancelQueued, - interruptingQueuedRunId, currentUserId, userProfileMap, } = useContext(IssueChatCtx); @@ -1201,10 +1229,10 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) { size="sm" variant="outline" className="h-6 border-red-300 px-2 text-[11px] text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10" - disabled={interruptingQueuedRunId === queueTargetRunId} + disabled={isInterruptingQueuedRun} onClick={() => void onInterruptQueued(queueTargetRunId)} > - {interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"} + {isInterruptingQueuedRun ? "Interrupting..." : "Interrupt"} ) : null} {onCancelQueued ? ( @@ -1290,16 +1318,23 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) { ); } -function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) { +function IssueChatAssistantMessage({ + message, + activeVote, + isRunActive, + isStoppingRun, +}: { + message: ThreadMessage; + activeVote: FeedbackVoteValue | null; + isRunActive: boolean; + isStoppingRun: boolean; +}) { const { - feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl, onVote, agentMap, - activeRunIds, onStopRun, - stoppingRunId, } = useContext(IssueChatCtx); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; @@ -1321,7 +1356,7 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) { const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : ""; const isRunning = message.role === "assistant" && message.status?.type === "running"; const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null; - const canStopRun = canStopIssueChatRun({ runId, runStatus, activeRunIds }); + const canStopRun = Boolean(runId) && (isRunActive || runStatus === "queued" || runStatus === "running"); const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null; const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call"); const isFoldable = !isRunning && !!chainOfThoughtLabel; @@ -1355,7 +1390,6 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) { await onVote(commentId, vote, options); }; - const activeVote = commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null; const followUpRequested = custom.followUpRequested === true; return ( @@ -1493,14 +1527,14 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) { {canStopRun && onStopRun && runId ? ( { void onStopRun(runId); }} > - {stoppingRunId === runId ? "Stopping…" : "Stop run"} + {isStoppingRun ? "Stopping…" : "Stop run"} ) : null} {runHref ? ( @@ -2022,6 +2056,546 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { return null; } +function issueChatMessageCustom(message: ThreadMessage): Record { + return (message.metadata?.custom ?? {}) as Record; +} + +function issueChatMessageKind(message: ThreadMessage): string { + const custom = issueChatMessageCustom(message); + return typeof custom.kind === "string" ? custom.kind : message.role; +} + +function issueChatMessageCommentId(message: ThreadMessage): string | null { + const custom = issueChatMessageCustom(message); + return typeof custom.commentId === "string" ? custom.commentId : null; +} + +function issueChatMessageRunId(message: ThreadMessage): string | null { + const custom = issueChatMessageCustom(message); + return typeof custom.runId === "string" ? custom.runId : null; +} + +function issueChatMessageQueueTargetRunId(message: ThreadMessage): string | null { + const custom = issueChatMessageCustom(message); + return typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null; +} + +function issueChatMessageActiveVote( + message: ThreadMessage, + feedbackVoteByTargetId: ReadonlyMap, +): FeedbackVoteValue | null { + const commentId = issueChatMessageCommentId(message); + return commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null; +} + +function issueChatMessageRunIsActive( + message: ThreadMessage, + activeRunIds: ReadonlySet, +): boolean { + const runId = issueChatMessageRunId(message); + return Boolean(runId && activeRunIds.has(runId)); +} + +function issueChatMessageRunIsStopping( + message: ThreadMessage, + stoppingRunId: string | null | undefined, +): boolean { + const runId = issueChatMessageRunId(message); + return Boolean(runId && stoppingRunId === runId); +} + +function issueChatMessageQueuedRunIsInterrupting( + message: ThreadMessage, + interruptingQueuedRunId: string | null | undefined, +): boolean { + const queueTargetRunId = issueChatMessageQueueTargetRunId(message); + return Boolean(queueTargetRunId && interruptingQueuedRunId === queueTargetRunId); +} + +// Above ~150 merged rows the direct render path forces React to mount and +// re-render hundreds of Markdown bodies, feedback controls, and avatars on +// unrelated parent updates. Above this threshold we switch to a windowed +// render path so only visible rows plus overscan stay mounted. +export const VIRTUALIZED_THREAD_ROW_THRESHOLD = 150; +const VIRTUALIZED_THREAD_OVERSCAN = 6; +// Rough "average row" estimate. The virtualizer measures real heights as +// rows mount, so this only affects offscreen rows it has not seen yet. +const VIRTUALIZED_THREAD_ROW_ESTIMATE_PX = 220; +const VIRTUALIZED_THREAD_GAP_FULL_PX = 16; +const VIRTUALIZED_THREAD_GAP_EMBEDDED_PX = 12; + +interface VirtualizedIssueChatThreadListProps { + messages: readonly ThreadMessage[]; + feedbackVoteByTargetId: ReadonlyMap; + activeRunIds: ReadonlySet; + stoppingRunId?: string | null; + interruptingQueuedRunId?: string | null; + variant: "full" | "embedded"; +} + +interface VirtualizedIssueChatThreadListHandle { + scrollToIndex: ( + index: number, + options?: { align?: "start" | "center" | "end" | "auto"; behavior?: ScrollBehavior }, + ) => void; + scrollToLatest: (options?: { behavior?: ScrollBehavior }) => void; + measure: () => void; +} + +function issueChatMessageAnchorId(message: ThreadMessage): string | null { + const custom = message.metadata.custom as { anchorId?: unknown } | undefined; + return typeof custom?.anchorId === "string" ? custom.anchorId : null; +} + +function findMessageAnchorIndex(messages: readonly ThreadMessage[], anchorId: string): number { + return messages.findIndex((message) => issueChatMessageAnchorId(message) === anchorId); +} + +export function findLatestCommentMessageIndex(messages: readonly ThreadMessage[]): number { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const anchorId = issueChatMessageAnchorId(messages[index]); + if (anchorId && anchorId.startsWith("comment-")) return index; + } + return -1; +} + +type VirtualizedVisibleAnchorSnapshot = { + anchorId: string; + index: number; + viewportTop: number; +}; + +type VirtualizedScrollMode = + | { kind: "window" } + | { kind: "element"; element: HTMLElement }; + +type SimpleVirtualItem = { + index: number; + key: React.Key; + start: number; + size: number; +}; + +function useIssueThreadVirtualizer({ + count, + estimateSize, + overscan, + scrollMargin, + gap, + getItemKey, + mode, +}: { + count: number; + estimateSize: () => number; + overscan: number; + scrollMargin: number; + gap: number; + getItemKey: (index: number) => React.Key; + mode: VirtualizedScrollMode; +}) { + const measuredSizeByKeyRef = useRef(new Map()); + const [, rerender] = useState(0); + const estimatedSize = estimateSize(); + + const itemStarts: number[] = []; + const itemSizes: number[] = []; + let nextStart = scrollMargin; + for (let index = 0; index < count; index += 1) { + const key = getItemKey(index); + const size = measuredSizeByKeyRef.current.get(key) ?? estimatedSize; + itemStarts.push(nextStart); + itemSizes.push(size); + nextStart += size + gap; + } + const totalSize = Math.max(0, nextStart - scrollMargin - gap); + + const viewportHeight = () => (mode.kind === "window" ? window.innerHeight : mode.element.clientHeight); + const scrollOffset = () => (mode.kind === "window" ? window.scrollY : mode.element.scrollTop); + const maxScrollOffset = () => { + const targetScrollHeight = mode.kind === "window" + ? document.documentElement.scrollHeight + : mode.element.scrollHeight; + return Math.max(0, Math.max(targetScrollHeight, totalSize) - viewportHeight()); + }; + + useEffect(() => { + if (typeof window === "undefined") return; + const target: Window | HTMLElement = mode.kind === "window" ? window : mode.element; + const update = () => rerender((value) => value + 1); + target.addEventListener("scroll", update, { passive: true }); + window.addEventListener("resize", update); + return () => { + target.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + }; + }, [mode]); + + const rawStart = Math.max(scrollMargin, scrollOffset()); + const rawEnd = rawStart + viewportHeight(); + let visibleStartIndex = 0; + while ( + visibleStartIndex < count - 1 + && itemStarts[visibleStartIndex] + itemSizes[visibleStartIndex] < rawStart + ) { + visibleStartIndex += 1; + } + let visibleEndIndex = visibleStartIndex; + while (visibleEndIndex < count - 1 && itemStarts[visibleEndIndex] <= rawEnd) { + visibleEndIndex += 1; + } + const startIndex = Math.max(0, visibleStartIndex - overscan); + const endIndex = Math.min(count - 1, visibleEndIndex + overscan); + const virtualItems: SimpleVirtualItem[] = []; + for (let index = startIndex; index <= endIndex; index += 1) { + virtualItems.push({ + index, + key: getItemKey(index), + start: itemStarts[index] ?? scrollMargin, + size: itemSizes[index] ?? estimatedSize, + }); + } + + const scrollToIndex = ( + index: number, + options?: { align?: "start" | "center" | "end" | "auto"; behavior?: ScrollBehavior }, + ) => { + const clampedIndex = Math.max(0, Math.min(index, count - 1)); + const targetMax = maxScrollOffset(); + let top = itemStarts[clampedIndex] ?? scrollMargin; + if (options?.align === "center") { + top = top - viewportHeight() / 2 + (itemSizes[clampedIndex] ?? estimatedSize) / 2; + } else if (options?.align === "end") { + top = top + (itemSizes[clampedIndex] ?? estimatedSize) - viewportHeight(); + } + top = Math.max(0, Math.min(top, targetMax)); + if (mode.kind === "window") { + window.scrollTo({ top, behavior: options?.behavior }); + } else { + mode.element.scrollTo({ top, behavior: options?.behavior }); + } + rerender((value) => value + 1); + }; + + return { + getVirtualItems: () => virtualItems, + getTotalSize: () => totalSize, + scrollToIndex, + measure: () => undefined, + measureElement: (element?: HTMLElement | null) => { + if (!element) return; + const index = Number(element.dataset.index); + if (!Number.isInteger(index) || index < 0 || index >= count) return; + const measuredSize = element.getBoundingClientRect().height || element.offsetHeight; + if (!Number.isFinite(measuredSize) || measuredSize <= 0) return; + const key = getItemKey(index); + const previousSize = measuredSizeByKeyRef.current.get(key) ?? estimatedSize; + if (Math.abs(previousSize - measuredSize) < 1) return; + measuredSizeByKeyRef.current.set(key, measuredSize); + rerender((value) => value + 1); + }, + }; +} + +// The chat thread renders inside `
` on the real issue +// page (overflow-auto on desktop), but lives at document scope on mobile (main +// is overflow-visible) and in the auth-free perf fixture. Walk the DOM to find +// the actual scroll container so the virtualizer binds to the right offset +// source — otherwise it stays anchored at offset 0 forever and the visible +// chat area renders blank past the first viewport (PAP-2660). +function findScrollContainer(el: HTMLElement | null): HTMLElement | null { + if (!el || typeof window === "undefined") return null; + let current: HTMLElement | null = el.parentElement; + while (current && current !== document.body && current !== document.documentElement) { + const overflowY = window.getComputedStyle(current).overflowY; + if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") { + return current; + } + current = current.parentElement; + } + return null; +} + +const VirtualizedIssueChatThreadList = forwardRef(function VirtualizedIssueChatThreadList(props, ref) { + const probeRef = useRef(null); + // Default to window scroll on first render so the imperative handle is + // available immediately for hash-target / submit-scroll effects. After mount + // we probe the DOM and remount via key={modeKey} if the actual scroll + // container is an element ancestor (e.g. desktop
). + const [mode, setMode] = useState({ kind: "window" }); + + useLayoutEffect(() => { + if (typeof window === "undefined") return; + const detect = () => { + const probe = probeRef.current; + if (!probe) return; + const container = findScrollContainer(probe); + setMode((prev) => { + if (container === null) { + return prev.kind === "window" ? prev : { kind: "window" }; + } + if (prev.kind === "element" && prev.element === container) return prev; + return { kind: "element", element: container }; + }); + }; + detect(); + window.addEventListener("resize", detect); + return () => { + window.removeEventListener("resize", detect); + }; + }, []); + + return ( + + ); +}); + +interface VirtualizedIssueChatThreadListInnerProps extends VirtualizedIssueChatThreadListProps { + mode: VirtualizedScrollMode; + probeRef: React.MutableRefObject; +} + +const VirtualizedIssueChatThreadListInner = forwardRef< + VirtualizedIssueChatThreadListHandle, + VirtualizedIssueChatThreadListInnerProps +>(function VirtualizedIssueChatThreadListInner({ + messages, + feedbackVoteByTargetId, + activeRunIds, + stoppingRunId, + interruptingQueuedRunId, + variant, + mode, + probeRef, +}, ref) { + const parentRef = useRef(null); + const [scrollMargin, setScrollMargin] = useState(0); + const pendingPrependAnchorRef = useRef(null); + + const setRefs = useCallback((element: HTMLDivElement | null) => { + parentRef.current = element; + probeRef.current = element; + }, [probeRef]); + + useLayoutEffect(() => { + const element = parentRef.current; + if (!element || typeof window === "undefined") return; + const update = () => { + if (!parentRef.current) return; + const rect = parentRef.current.getBoundingClientRect(); + const offset = mode.kind === "window" + ? rect.top + window.scrollY + : rect.top - mode.element.getBoundingClientRect().top + mode.element.scrollTop; + setScrollMargin((previous) => (Math.abs(previous - offset) < 0.5 ? previous : offset)); + }; + update(); + window.addEventListener("resize", update); + return () => { + window.removeEventListener("resize", update); + }; + }, [mode]); + + const gap = variant === "embedded" + ? VIRTUALIZED_THREAD_GAP_EMBEDDED_PX + : VIRTUALIZED_THREAD_GAP_FULL_PX; + + const virtualizer = useIssueThreadVirtualizer({ + count: messages.length, + estimateSize: () => VIRTUALIZED_THREAD_ROW_ESTIMATE_PX, + overscan: VIRTUALIZED_THREAD_OVERSCAN, + scrollMargin, + gap, + getItemKey: (index) => messages[index]?.id ?? index, + mode, + }); + + useImperativeHandle(ref, () => ({ + scrollToIndex: (index, options) => { + if (index < 0 || index >= messages.length) return; + virtualizer.scrollToIndex(index, { + align: options?.align ?? "center", + behavior: options?.behavior ?? "smooth", + }); + }, + scrollToLatest: (options) => { + if (messages.length === 0) return; + virtualizer.scrollToIndex(messages.length - 1, { + align: "end", + behavior: options?.behavior ?? "smooth", + }); + }, + measure: () => { + virtualizer.measure(); + }, + }), [messages.length, virtualizer]); + + useLayoutEffect(() => { + return () => { + const element = parentRef.current; + if (!element || typeof window === "undefined") return; + const rows = Array.from( + element.querySelectorAll("[data-anchor-id][data-index]"), + ); + const visibleRow = rows.find((row) => row.getBoundingClientRect().bottom >= 0); + if (!visibleRow) return; + const anchorId = visibleRow.dataset.anchorId; + const index = Number(visibleRow.dataset.index); + if (!anchorId || !Number.isFinite(index)) return; + pendingPrependAnchorRef.current = { + anchorId, + index, + viewportTop: visibleRow.getBoundingClientRect().top, + }; + }; + }, [messages]); + + useLayoutEffect(() => { + const pendingAnchor = pendingPrependAnchorRef.current; + pendingPrependAnchorRef.current = null; + virtualizer.measure(); + if (!pendingAnchor || typeof window === "undefined") return; + const nextIndex = findMessageAnchorIndex(messages, pendingAnchor.anchorId); + if (nextIndex <= pendingAnchor.index) return; + + virtualizer.scrollToIndex(nextIndex, { align: "start", behavior: "auto" }); + requestAnimationFrame(() => { + const element = document.getElementById(pendingAnchor.anchorId); + if (!element) return; + const delta = element.getBoundingClientRect().top - pendingAnchor.viewportTop; + if (Math.abs(delta) > 1) { + if (mode.kind === "window") { + window.scrollBy({ top: delta, behavior: "auto" }); + } else { + mode.element.scrollBy({ top: delta, behavior: "auto" }); + } + } + virtualizer.measure(); + }); + }, [messages, virtualizer, mode]); + + const virtualItems = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + + return ( +
+ {virtualItems.map((virtualItem) => { + const message = messages[virtualItem.index]; + if (!message) return null; + const anchorId = issueChatMessageAnchorId(message); + return ( +
{ + if (element) virtualizer.measureElement(element); + }} + onLoadCapture={(event) => { + virtualizer.measureElement(event.currentTarget); + }} + onClickCapture={(event) => { + const row = event.currentTarget; + requestAnimationFrame(() => { + virtualizer.measureElement(row); + }); + }} + onTransitionEndCapture={(event) => { + virtualizer.measureElement(event.currentTarget); + }} + style={{ + position: "absolute", + top: 0, + left: 0, + right: 0, + transform: `translateY(${virtualItem.start - scrollMargin}px)`, + }} + > + +
+ ); + })} +
+ ); +}); + +interface IssueChatMessageRowProps { + message: ThreadMessage; + feedbackVoteByTargetId: ReadonlyMap; + activeRunIds: ReadonlySet; + stoppingRunId?: string | null; + interruptingQueuedRunId?: string | null; +} + +const IssueChatMessageRow = memo(function IssueChatMessageRow({ + message, + feedbackVoteByTargetId, + activeRunIds, + stoppingRunId, + interruptingQueuedRunId, +}: IssueChatMessageRowProps) { + const kind = issueChatMessageKind(message); + const activeVote = issueChatMessageActiveVote(message, feedbackVoteByTargetId); + const isRunActive = issueChatMessageRunIsActive(message, activeRunIds); + const isStoppingRun = issueChatMessageRunIsStopping(message, stoppingRunId); + const isInterruptingQueuedRun = issueChatMessageQueuedRunIsInterrupting(message, interruptingQueuedRunId); + const renderedMessage = message.role === "user" + ? ( + + ) + : message.role === "assistant" + ? ( + + ) + : ; + + return ( +
+ {renderedMessage} +
+ ); +}, areIssueChatMessageRowPropsEqual); + +function areIssueChatMessageRowPropsEqual( + prev: IssueChatMessageRowProps, + next: IssueChatMessageRowProps, +) { + if (prev.message !== next.message) return false; + if (issueChatMessageActiveVote(prev.message, prev.feedbackVoteByTargetId) !== issueChatMessageActiveVote(next.message, next.feedbackVoteByTargetId)) return false; + if (issueChatMessageRunIsActive(prev.message, prev.activeRunIds) !== issueChatMessageRunIsActive(next.message, next.activeRunIds)) return false; + if (issueChatMessageRunIsStopping(prev.message, prev.stoppingRunId) !== issueChatMessageRunIsStopping(next.message, next.stoppingRunId)) return false; + if (issueChatMessageQueuedRunIsInterrupting(prev.message, prev.interruptingQueuedRunId) !== issueChatMessageQueuedRunIsInterrupting(next.message, next.interruptingQueuedRunId)) return false; + return true; +} + const IssueChatComposer = forwardRef(function IssueChatComposer({ onImageUpload, onAttachImage, @@ -2037,6 +2611,7 @@ const IssueChatComposer = forwardRef(null); const editorRef = useRef(null); const composerContainerRef = useRef(null); @@ -2091,6 +2667,10 @@ const IssueChatComposer = forwardRef { + setUnassignedConfirmed(false); + }, [reassignTarget]); + useImperativeHandle(forwardedRef, () => ({ focus: focusComposer, restoreDraft: (submittedBody: string) => { @@ -2108,6 +2688,22 @@ const IssueChatComposer = forwardRef 0; + if ( + composerHasAssigneePicker + && isUnassignedReassignValue(reassignTarget) + && !unassignedConfirmed + ) { + toastActions?.pushToast({ + title: "No assignee selected", + body: "Pick an assignee or click Send again to post without one.", + tone: "warn", + dedupeKey: `issue-chat-no-assignee:${draftKey ?? ""}`, + }); + setUnassignedConfirmed(true); + return; + } + const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; const reassignment = hasReassignment ? parseReassignment(reassignTarget) : undefined; const reopen = shouldImplicitlyReopenComment( @@ -2119,6 +2715,7 @@ const IssueChatComposer = forwardRef(null); + const virtualizedThreadRef = useRef(null); const bottomAnchorRef = useRef(null); const composerViewportAnchorRef = useRef(null); const composerViewportSnapshotRef = useRef>(null); @@ -2614,6 +3213,42 @@ export function IssueChatThread({ } return map; }, [feedbackVotes]); + const useVirtualizedThread = messages.length >= VIRTUALIZED_THREAD_ROW_THRESHOLD; + const messageAnchorIndex = useMemo(() => { + const map = new Map(); + messages.forEach((message, index) => { + const anchorId = issueChatMessageAnchorId(message); + if (anchorId) map.set(anchorId, index); + }); + return map; + }, [messages]); + + function scrollToThreadAnchor( + anchorId: string, + options?: { align?: "start" | "center" | "end" | "auto"; behavior?: ScrollBehavior }, + ) { + const virtualIndex = messageAnchorIndex.get(anchorId); + if (useVirtualizedThread && virtualIndex !== undefined) { + if (!virtualizedThreadRef.current) return false; + virtualizedThreadRef.current.scrollToIndex(virtualIndex, { + align: options?.align ?? "center", + behavior: options?.behavior ?? "smooth", + }); + return true; + } + + const element = document.getElementById(anchorId); + if (!element) return false; + element.scrollIntoView({ + behavior: options?.behavior ?? "smooth", + block: options?.align === "start" + ? "start" + : options?.align === "end" + ? "end" + : "center", + }); + return true; + } const runtime = usePaperclipIssueRuntime({ messages, @@ -2643,14 +3278,13 @@ export function IssueChatThread({ spacerInitialReserveRef.current = reserve; setBottomSpacerHeight(reserve); requestAnimationFrame(() => { - const el = document.getElementById(anchorId); - el?.scrollIntoView({ behavior: "smooth", block: "start" }); + scrollToThreadAnchor(anchorId, { align: "start", behavior: "smooth" }); }); } } lastUserMessageIdRef.current = lastUserId; - }, [messages]); + }, [messageAnchorIndex, messages, useVirtualizedThread]); useLayoutEffect(() => { const anchorId = spacerBaselineAnchorRef.current; @@ -2683,7 +3317,7 @@ export function IssueChatThread({ }, [messages]); useEffect(() => { - const hash = location.hash; + const hash = location.hash || (typeof window !== "undefined" ? window.location.hash : ""); if ( !( hash.startsWith("#comment-") @@ -2692,58 +3326,141 @@ export function IssueChatThread({ || hash.startsWith("#interaction-") ) ) return; - if (messages.length === 0 || hasScrolledRef.current) return; + if (messages.length === 0 || lastScrolledHashRef.current === hash) return; const targetId = hash.slice(1); - const element = document.getElementById(targetId); - if (!element) return; - hasScrolledRef.current = true; - element.scrollIntoView({ behavior: "smooth", block: "center" }); - }, [location.hash, messages]); + let cancelled = false; + const attemptScroll = (finalAttempt = false) => { + if (cancelled || lastScrolledHashRef.current === hash) return; + const didScroll = scrollToThreadAnchor(targetId, { align: "center", behavior: "smooth" }); + if (!didScroll) return; + if (finalAttempt || !useVirtualizedThread || document.getElementById(targetId)) { + lastScrolledHashRef.current = hash; + } + }; - function handleJumpToLatest() { + attemptScroll(); + const frame = requestAnimationFrame(() => attemptScroll()); + const timeout = window.setTimeout(() => attemptScroll(true), 250); + return () => { + cancelled = true; + cancelAnimationFrame(frame); + window.clearTimeout(timeout); + }; + }, [location.hash, messageAnchorIndex, messages, useVirtualizedThread]); + + function jumpToLatestFallback() { + if (useVirtualizedThread) { + virtualizedThreadRef.current?.scrollToLatest({ behavior: "smooth" }); + return; + } bottomAnchorRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); } + // Walks the thread by anchor and lands on the latest `comment-*` row, with + // a short series of settle passes. The virtualizer estimates row sizes for + // unmeasured rows, and that estimate undershoots tall markdown comments — + // so the first scroll often lands above the actual bottom and the user + // ends up clicking Jump to latest repeatedly to converge. Re-issuing the + // scroll after measurements catch up lets one click reach the actual + // latest comment (PAP-2672 follow-up). + function scrollToLatestCommentWithSettle() { + const latestCommentIndex = findLatestCommentMessageIndex(messages); + if (latestCommentIndex < 0) { + jumpToLatestFallback(); + return; + } + const latestCommentAnchor = issueChatMessageAnchorId(messages[latestCommentIndex]); + if (!latestCommentAnchor) { + jumpToLatestFallback(); + return; + } + + const initial = scrollToThreadAnchor(latestCommentAnchor, { align: "end", behavior: "smooth" }); + if (!initial) { + jumpToLatestFallback(); + return; + } + + if (typeof window === "undefined") return; + + const settleDelays = [380, 760, 1140]; + settleDelays.forEach((delay) => { + window.setTimeout(() => { + const el = document.getElementById(latestCommentAnchor); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "end" }); + return; + } + // The row may still be outside the virtualizer's render buffer; nudge + // the offset so it gets mounted, then the next pass can align with + // real DOM measurements. + virtualizedThreadRef.current?.scrollToIndex(latestCommentIndex, { + align: "end", + behavior: "auto", + }); + }, delay); + }); + } + + function handleJumpToLatest() { + if (onRefreshLatestComments) { + // Refetching from page 0 (newest first) brings any comments that + // arrived after the initial load into the cache before we scroll — + // otherwise we'd land on the latest *loaded* row rather than the + // absolute newest, which is what PAP-2672 reopened on. + const refreshed = onRefreshLatestComments(); + if (refreshed && typeof (refreshed as Promise).then === "function") { + (refreshed as Promise).then( + () => scrollToLatestCommentWithSettle(), + () => scrollToLatestCommentWithSettle(), + ); + return; + } + } + scrollToLatestCommentWithSettle(); + } + + const stableOnVote = useStableEvent(onVote); + const stableOnStopRun = useStableEvent(onStopRun); + const stableOnInterruptQueued = useStableEvent(onInterruptQueued); + const stableOnCancelQueued = useStableEvent(onCancelQueued); + const stableOnImageClick = useStableEvent(onImageClick); + const stableOnAcceptInteraction = useStableEvent(onAcceptInteraction); + const stableOnRejectInteraction = useStableEvent(onRejectInteraction); + const stableOnSubmitInteractionAnswers = useStableEvent(onSubmitInteractionAnswers); + const chatCtx = useMemo( () => ({ - feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl, agentMap, currentUserId, userLabelMap, userProfileMap, - activeRunIds, - onVote, - onStopRun, - stoppingRunId, - onInterruptQueued, - onCancelQueued, - interruptingQueuedRunId, - onImageClick, - onAcceptInteraction, - onRejectInteraction, - onSubmitInteractionAnswers, + onVote: stableOnVote, + onStopRun: stableOnStopRun, + onInterruptQueued: stableOnInterruptQueued, + onCancelQueued: stableOnCancelQueued, + onImageClick: stableOnImageClick, + onAcceptInteraction: stableOnAcceptInteraction, + onRejectInteraction: stableOnRejectInteraction, + onSubmitInteractionAnswers: stableOnSubmitInteractionAnswers, }), [ - feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl, agentMap, currentUserId, userLabelMap, userProfileMap, - activeRunIds, - onVote, - onStopRun, - stoppingRunId, - onInterruptQueued, - onCancelQueued, - interruptingQueuedRunId, - onImageClick, - onAcceptInteraction, - onRejectInteraction, - onSubmitInteractionAnswers, + stableOnVote, + stableOnStopRun, + stableOnInterruptQueued, + stableOnCancelQueued, + stableOnImageClick, + stableOnAcceptInteraction, + stableOnRejectInteraction, + stableOnSubmitInteractionAnswers, ], ); @@ -2752,10 +3469,13 @@ export function IssueChatThread({ ?? (variant === "embedded" ? "No run output yet." : "This issue conversation is empty. Start with a message below."); - const errorBoundaryResetKey = useMemo( - () => messages.map((message) => `${message.id}:${message.role}:${message.content.length}:${message.status?.type ?? "none"}`).join("|"), - [messages], - ); + const previousErrorBoundaryMessagesRef = useRef(null); + const errorBoundaryResetVersionRef = useRef(0); + if (previousErrorBoundaryMessagesRef.current !== messages) { + previousErrorBoundaryMessagesRef.current = messages; + errorBoundaryResetVersionRef.current += 1; + } + const errorBoundaryResetKey = String(errorBoundaryResetVersionRef.current); return ( @@ -2793,19 +3513,30 @@ export function IssueChatThread({ )}> {resolvedEmptyMessage}
+ ) : messages.length >= VIRTUALIZED_THREAD_ROW_THRESHOLD ? ( + ) : ( // Keep transcript rendering independent from assistant-ui's // index-scoped message providers; live transcripts can shrink // or regroup while the runtime still holds stale indices. - messages.map((message) => { - if (message.role === "user") { - return ; - } - if (message.role === "assistant") { - return ; - } - return ; - }) + messages.map((message) => ( + + )) )} {showComposer ? (
diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index d6969d23..664990b1 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; -import { X } from "lucide-react"; +import { Eye, X } from "lucide-react"; import { createIssueDetailPath, rememberIssueDetailLocationState, @@ -9,6 +9,7 @@ import { } from "../lib/issueDetailBreadcrumb"; import { cn } from "../lib/utils"; import { StatusIcon } from "./StatusIcon"; +import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge"; type UnreadState = "hidden" | "visible" | "fading"; @@ -63,6 +64,19 @@ export function IssueRow({ const showUnreadDot = unreadState === "visible" || unreadState === "fading"; const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined; const detailState = withIssueDetailHeaderSeed(issueLinkState, issue); + const productivityReview = issue.productivityReview ?? null; + const productivityReviewIndicator = productivityReview ? ( + + + + ) : null; const hasChecklistStep = checklistStepNumber !== null; const checklistStep = hasChecklistStep ? (