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}