From a904effb963da5f895a532bb5c63e92a528207bc Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 7 May 2026 16:45:12 -0700 Subject: [PATCH] Add experimental newest-first issue thread (#5455) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies, so issue threads are a core operator surface for reviewing work. > - The issue detail page is the place where humans read agent messages, user comments, and execution context together. > - That thread originally rendered oldest-first, which made recent activity harder to see during active review. > - Reversing the thread order changes navigation expectations, timestamp placement, and the "Jump to latest" affordance, so the UI behavior needed to move as a coherent set. > - Because this is a visible core-product behavior shift, it also needed a safe rollout path instead of becoming the default immediately. > - This pull request adds the newest-first issue thread behavior behind an Experimental setting, updates the thread UI to match that mode, and keeps the legacy oldest-first experience unchanged by default. > - The benefit is that reviewers can opt into a more recent-first issue workflow without forcing a global behavior change on every Paperclip instance. ## What Changed - Reversed issue thread rendering so the newest comments and messages appear first when the experiment is enabled. - Moved the plain comment timestamp into the card header in newest-first mode and kept the legacy timestamp placement for oldest-first mode. - Moved the `Jump to latest` control to the bottom of the thread in newest-first mode while leaving the existing top placement for the legacy mode. - Added the `Enable Newest-First Issue Thread` experimental instance setting and wired issue detail to read that toggle. - Added regression coverage for thread order, timestamp placement, jump-button placement, and the issue-detail experiment toggle behavior. ## Verification - `pnpm -r typecheck` - `pnpm test:run` - `pnpm build` - Focused checks that also passed during issue review: - `pnpm vitest run src/components/IssueChatThread.test.tsx src/pages/IssueDetail.test.tsx` in `ui/` - `pnpm vitest run src/__tests__/instance-settings-routes.test.ts` in `server/` - Manual review path: - Enable `Instance Settings > Experimental > Enable Newest-First Issue Thread` - Open an issue with comments/messages and confirm newest activity renders first, timestamps move into the header, and `Jump to latest` sits below the thread - Disable the experiment and confirm the legacy oldest-first behavior returns ## Risks - Low risk: the behavioral change is gated behind an instance-level experimental toggle and defaults off. - The main regression risk is thread navigation drift between the two modes, especially around anchor scrolling and the `Jump to latest` affordance. - There is some UI coupling between issue-detail query state and experimental settings fetches, so future changes in that area should keep both modes covered. - Screenshots are not attached in this PR body; verification is described with automated coverage and manual steps instead. > I checked [`ROADMAP.md`](ROADMAP.md). This is a scoped issue-thread UX improvement and rollout gate, not a duplicate of a roadmap-level planned core feature. ## Model Used - OpenAI Codex via the local `codex_local` Paperclip adapter, GPT-5-based coding agent with terminal tool use and local code execution in this repository worktree. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- packages/shared/src/types/instance.ts | 1 + packages/shared/src/validators/instance.ts | 1 + .../instance-settings-routes.test.ts | 3 + server/src/services/instance-settings.ts | 2 + ui/src/components/IssueChatThread.test.tsx | 174 +++++++++++- ui/src/components/IssueChatThread.tsx | 266 +++++++++++------- ui/src/pages/InstanceExperimentalSettings.tsx | 20 ++ ui/src/pages/IssueDetail.test.tsx | 44 +++ ui/src/pages/IssueDetail.tsx | 10 + 9 files changed, 415 insertions(+), 106 deletions(-) diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index ee6a6553..c9328ac0 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -29,6 +29,7 @@ export interface InstanceGeneralSettings { export interface InstanceExperimentalSettings { enableEnvironments: boolean; enableIsolatedWorkspaces: boolean; + enableNewestFirstIssueThread: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; issueGraphLivenessAutoRecoveryLookbackHours: number; diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 3415539a..4c77448d 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -38,6 +38,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema. export const instanceExperimentalSettingsSchema = z.object({ enableEnvironments: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false), + enableNewestFirstIssueThread: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), issueGraphLivenessAutoRecoveryLookbackHours: z diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 41e52190..d09ef151 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -64,6 +64,7 @@ describe("instance settings routes", () => { mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableEnvironments: false, enableIsolatedWorkspaces: false, + enableNewestFirstIssueThread: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -81,6 +82,7 @@ describe("instance settings routes", () => { experimental: { enableEnvironments: true, enableIsolatedWorkspaces: true, + enableNewestFirstIssueThread: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -123,6 +125,7 @@ describe("instance settings routes", () => { expect(getRes.body).toEqual({ enableEnvironments: false, enableIsolatedWorkspaces: false, + enableNewestFirstIssueThread: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index c447a920..5806a1f6 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -41,6 +41,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin return { enableEnvironments: parsed.data.enableEnvironments ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, + enableNewestFirstIssueThread: parsed.data.enableNewestFirstIssueThread ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, issueGraphLivenessAutoRecoveryLookbackHours: @@ -51,6 +52,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin return { enableEnvironments: false, enableIsolatedWorkspaces: false, + enableNewestFirstIssueThread: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 3918b39b..61d3a8ce 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -295,16 +295,19 @@ function createFileDragEvent(type: string, files: File[]) { describe("IssueChatThread", () => { let container: HTMLDivElement; + const originalDocumentElementScrollIntoView = document.documentElement.scrollIntoView; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); window.scrollTo = vi.fn(); + document.documentElement.scrollIntoView = vi.fn() as unknown as typeof document.documentElement.scrollIntoView; localStorage.clear(); }); afterEach(() => { container.remove(); + document.documentElement.scrollIntoView = originalDocumentElementScrollIntoView; vi.useRealTimers(); appendMock.mockReset(); markdownEditorFocusMock.mockReset(); @@ -327,6 +330,7 @@ describe("IssueChatThread", () => { liveRuns={[]} onAdd={async () => {}} showComposer={false} + newestFirst enableLiveTranscriptPolling={false} /> , @@ -336,6 +340,16 @@ describe("IssueChatThread", () => { expect(container.textContent).toContain("Jump to latest"); expect(container.textContent).not.toContain("Chat ("); + const threadRoot = container.querySelector('[data-testid="thread-root"]'); + const jumpButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Jump to latest", + ); + expect(threadRoot).not.toBeNull(); + expect(jumpButton).toBeDefined(); + expect( + threadRoot?.compareDocumentPosition(jumpButton!), + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null; expect(viewport).not.toBeNull(); expect(viewport?.className).not.toContain("overflow-y-auto"); @@ -346,6 +360,106 @@ describe("IssueChatThread", () => { }); }); + it("defaults to oldest-first rendering and jump placement", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const rows = Array.from(container.querySelectorAll('[data-testid="issue-chat-message-row"]')); + expect(rows[0]?.textContent).toContain("Older comment"); + expect(rows[1]?.textContent).toContain("Newer comment"); + + const threadRoot = container.querySelector('[data-testid="thread-root"]'); + const jumpButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Jump to latest", + ); + expect(threadRoot).not.toBeNull(); + expect(jumpButton).toBeDefined(); + expect( + threadRoot?.compareDocumentPosition(jumpButton!), + ).toBe(Node.DOCUMENT_POSITION_PRECEDING); + + act(() => { + root.unmount(); + }); + }); + + it("renders the jump control above the thread when newest-first mode is disabled", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + showComposer={false} + newestFirst={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const threadRoot = container.querySelector('[data-testid="thread-root"]'); + const jumpButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Jump to latest", + ); + expect(threadRoot).not.toBeNull(); + expect(jumpButton).toBeDefined(); + expect( + threadRoot?.compareDocumentPosition(jumpButton!), + ).toBe(Node.DOCUMENT_POSITION_PRECEDING); + + act(() => { + root.unmount(); + }); + }); + it("renders the composer in planning mode when the issue is in planning mode", () => { const root = createRoot(container); @@ -959,6 +1073,7 @@ describe("IssueChatThread", () => { agentMap={issueChatLongThreadAgentMap} currentUserId="user-board" onAdd={async () => {}} + newestFirst enableLiveTranscriptPolling={false} onRefreshLatestComments={async () => { setComments([olderComment, latestComment]); @@ -995,7 +1110,7 @@ describe("IssueChatThread", () => { }); }); - it("findLatestCommentMessageIndex prefers the last comment-anchored row (PAP-2672)", () => { + it("findLatestCommentMessageIndex prefers the first comment-anchored row when newest renders first", () => { const messages = [ { metadata: { custom: { anchorId: "comment-a" } } }, { metadata: { custom: { anchorId: "run-1" } } }, @@ -1003,7 +1118,7 @@ describe("IssueChatThread", () => { { metadata: { custom: { anchorId: "run-2" } } }, { metadata: { custom: { anchorId: "activity-3" } } }, ]; - expect(findLatestCommentMessageIndex(messages as never)).toBe(2); + expect(findLatestCommentMessageIndex(messages as never)).toBe(0); expect( findLatestCommentMessageIndex([ { metadata: { custom: { anchorId: "run-only" } } }, @@ -1012,6 +1127,17 @@ describe("IssueChatThread", () => { expect(findLatestCommentMessageIndex([] as never)).toBe(-1); }); + it("findLatestCommentMessageIndex prefers the last comment-anchored row when newest-first mode is disabled", () => { + 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, false)).toBe(2); + }); + it("keeps the direct render path for short threads under the virtualization threshold", () => { const root = createRoot(container); const directComments = issueChatLongThreadComments.slice(0, 12); @@ -1720,6 +1846,50 @@ describe("IssueChatThread", () => { }); }); + it("renders the comment timestamp above the comment body", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-08T12:00:00.000Z")); + const root = createRoot(container); + + act(() => { + root.render( + + {}} + showComposer={false} + newestFirst + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const text = container.textContent ?? ""; + const timestampIndex = text.indexOf("2d ago"); + expect(timestampIndex).toBeGreaterThanOrEqual(0); + expect(timestampIndex).toBeLessThan(text.indexOf("Agent summary")); + + act(() => { + root.unmount(); + }); + }); + it("shows deferred wake badge only for hold-deferred queued comments", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 36cf2eb7..778e845d 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -138,6 +138,7 @@ import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice"; interface IssueChatMessageContext { feedbackDataSharingPreference: FeedbackDataSharingPreference; feedbackTermsUrl: string | null; + newestFirst: boolean; agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; @@ -176,6 +177,7 @@ interface IssueChatMessageContext { const IssueChatCtx = createContext({ feedbackDataSharingPreference: "prompt", feedbackTermsUrl: null, + newestFirst: true, issueStatus: undefined, successfulRunHandoff: null, }); @@ -331,6 +333,7 @@ interface IssueChatThreadProps { onWorkModeChange?: (workMode: IssueWorkMode) => Promise | void; showComposer?: boolean; showJumpToLatest?: boolean; + newestFirst?: boolean; emptyMessage?: string; variant?: "full" | "embedded"; enableLiveTranscriptPolling?: boolean; @@ -530,7 +533,6 @@ function IssueChatFallbackThread({ const DRAFT_DEBOUNCE_MS = 800; const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96; -const SUBMIT_SCROLL_RESERVE_VH = 0.4; type ComposerAttachmentItem = { id: string; @@ -619,6 +621,33 @@ function commentDateLabel(date: Date | string | undefined): string { return formatShortDate(date); } +function IssueChatTimestampLink({ + anchorId, + createdAt, + className, +}: { + anchorId?: string; + createdAt?: Date | string; + className?: string; +}) { + if (!createdAt) return null; + return ( + + + + {commentDateLabel(createdAt)} + + + + {formatDateTime(createdAt)} + + + ); +} + const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) { const { onImageClick } = useContext(IssueChatCtx); if (isSuccessfulRunHandoffComment(text)) { @@ -1259,6 +1288,7 @@ function IssueChatUserMessage({ onCancelQueued, currentUserId, userProfileMap, + newestFirst, } = useContext(IssueChatCtx); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; @@ -1290,13 +1320,20 @@ function IssueChatUserMessage({ ); const messageBody = (
-
+
{resolvedAuthorName} {followUpRequested ? ( Follow-up ) : null} + {newestFirst ? ( + + ) : null}
- - - - {message.createdAt ? commentDateLabel(message.createdAt) : ""} - - - - {message.createdAt ? formatDateTime(message.createdAt) : ""} - - + {!newestFirst ? ( + + ) : null} ) : ( -
- {authorName} - {followUpRequested ? ( - - Follow-up - - ) : null} - {isRunning ? ( - - - Running - +
+
+ {authorName} + {followUpRequested ? ( + + Follow-up + + ) : null} + {isRunning ? ( + + + Running + + ) : null} +
+ {newestFirst ? ( + ) : null}
)} @@ -1582,19 +1622,12 @@ function IssueChatAssistantMessage({ onVote={handleVote} /> ) : null} - - - - {message.createdAt ? commentDateLabel(message.createdAt) : ""} - - - - {message.createdAt ? formatDateTime(message.createdAt) : ""} - - + {!newestFirst ? ( + + ) : null}
) : null} - +
{messages.length === 0 ? (
- )) - )} + )) + )} {showComposer ? (
) : null} -
{showComposer ? (
) : null}
+ {resolvedShowJumpToLatest && newestFirst ? ( +
+ +
+ ) : null} + {showComposer ? (
+
+
+
+

Enable Newest-First Issue Thread

+

+ Show issue comments and messages with the newest activity first, move the jump control to the bottom of + the page, and surface plain comment timestamps in the header area. +

+
+ + toggleMutation.mutate({ enableNewestFirstIssueThread: !enableNewestFirstIssueThread })} + disabled={toggleMutation.isPending} + aria-label="Toggle newest-first issue thread experimental setting" + /> +
+
+
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 331d71ca..c3b5a582 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -59,6 +59,7 @@ const mockProjectsApi = vi.hoisted(() => ({ const mockInstanceSettingsApi = vi.hoisted(() => ({ getGeneral: vi.fn(), + getExperimental: vi.fn(), })); const mockNavigate = vi.hoisted(() => vi.fn()); @@ -192,6 +193,7 @@ vi.mock("../components/InlineEditor", () => ({ vi.mock("../components/IssueChatThread", () => ({ IssueChatThread: (props: { + newestFirst?: boolean; onWorkModeChange?: (workMode: string) => void; issueWorkMode?: string; onStopRun?: (runId: string) => Promise; @@ -804,6 +806,9 @@ describe("IssueDetail", () => { keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableNewestFirstIssueThread: false, + }); mockIssuesListRender.mockClear(); mockIssueChatThreadRender.mockClear(); }); @@ -839,6 +844,45 @@ describe("IssueDetail", () => { expect(consoleErrorSpy).not.toHaveBeenCalled(); }); + it("passes oldest-first thread mode when the experimental flag is disabled", async () => { + mockIssuesApi.get.mockResolvedValue(createIssue()); + + await act(async () => { + root.render( + + + , + ); + }); + + await flushReact(); + + expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ + newestFirst: false, + }); + }); + + it("passes newest-first thread mode when the experimental flag is enabled", async () => { + mockIssuesApi.get.mockResolvedValue(createIssue()); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableNewestFirstIssueThread: true, + }); + + await act(async () => { + root.render( + + + , + ); + }); + + await flushReact(); + + expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ + newestFirst: true, + }); + }); + it("passes blocker attention to the issue detail header status icon", async () => { mockIssuesApi.get.mockResolvedValue(createIssue({ status: "blocked", diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 3663b4c8..c859be20 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -592,6 +592,7 @@ type IssueDetailChatTabProps = { issueId: string; companyId: string; projectId: string | null; + newestFirstIssueThreadEnabled: boolean; issueStatus: Issue["status"]; issueWorkMode: IssueWorkMode; executionRunId: string | null; @@ -655,6 +656,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ issueId, companyId, projectId, + newestFirstIssueThreadEnabled, issueWorkMode, issueStatus, executionRunId, @@ -855,6 +857,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ ) : null} 0 || resolvedHasActiveRun; + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + placeholderData: keepPreviousDataForSameQueryTail(issueId ?? "pending"), + }); + const newestFirstIssueThreadEnabled = experimentalSettings?.enableNewestFirstIssueThread === true; useEffect(() => { if (!hasLiveRuns && locallyQueuedCommentRunIds.size > 0) { setLocallyQueuedCommentRunIds(new Map()); @@ -3781,6 +3790,7 @@ export function IssueDetail() { issueId={issue.id} companyId={issue.companyId} projectId={issue.projectId ?? null} + newestFirstIssueThreadEnabled={newestFirstIssueThreadEnabled} issueStatus={issue.status} issueWorkMode={issue.workMode ?? "standard"} executionRunId={issue.executionRunId ?? null}