Revert "Add experimental newest-first issue thread" (#5460)

This is actually bad. Glad it was under experiments.
This commit is contained in:
Devin Foley 2026-05-07 16:50:31 -07:00 committed by GitHub
parent a904effb96
commit 0e1a582831
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 106 additions and 415 deletions

View file

@ -29,7 +29,6 @@ export interface InstanceGeneralSettings {
export interface InstanceExperimentalSettings { export interface InstanceExperimentalSettings {
enableEnvironments: boolean; enableEnvironments: boolean;
enableIsolatedWorkspaces: boolean; enableIsolatedWorkspaces: boolean;
enableNewestFirstIssueThread: boolean;
autoRestartDevServerWhenIdle: boolean; autoRestartDevServerWhenIdle: boolean;
enableIssueGraphLivenessAutoRecovery: boolean; enableIssueGraphLivenessAutoRecovery: boolean;
issueGraphLivenessAutoRecoveryLookbackHours: number; issueGraphLivenessAutoRecoveryLookbackHours: number;

View file

@ -38,7 +38,6 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.
export const instanceExperimentalSettingsSchema = z.object({ export const instanceExperimentalSettingsSchema = z.object({
enableEnvironments: z.boolean().default(false), enableEnvironments: z.boolean().default(false),
enableIsolatedWorkspaces: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false),
enableNewestFirstIssueThread: z.boolean().default(false),
autoRestartDevServerWhenIdle: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false),
enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false),
issueGraphLivenessAutoRecoveryLookbackHours: z issueGraphLivenessAutoRecoveryLookbackHours: z

View file

@ -64,7 +64,6 @@ describe("instance settings routes", () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableEnvironments: false, enableEnvironments: false,
enableIsolatedWorkspaces: false, enableIsolatedWorkspaces: false,
enableNewestFirstIssueThread: false,
autoRestartDevServerWhenIdle: false, autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true, enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24, issueGraphLivenessAutoRecoveryLookbackHours: 24,
@ -82,7 +81,6 @@ describe("instance settings routes", () => {
experimental: { experimental: {
enableEnvironments: true, enableEnvironments: true,
enableIsolatedWorkspaces: true, enableIsolatedWorkspaces: true,
enableNewestFirstIssueThread: false,
autoRestartDevServerWhenIdle: false, autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true, enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24, issueGraphLivenessAutoRecoveryLookbackHours: 24,
@ -125,7 +123,6 @@ describe("instance settings routes", () => {
expect(getRes.body).toEqual({ expect(getRes.body).toEqual({
enableEnvironments: false, enableEnvironments: false,
enableIsolatedWorkspaces: false, enableIsolatedWorkspaces: false,
enableNewestFirstIssueThread: false,
autoRestartDevServerWhenIdle: false, autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true, enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24, issueGraphLivenessAutoRecoveryLookbackHours: 24,

View file

@ -41,7 +41,6 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
return { return {
enableEnvironments: parsed.data.enableEnvironments ?? false, enableEnvironments: parsed.data.enableEnvironments ?? false,
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
enableNewestFirstIssueThread: parsed.data.enableNewestFirstIssueThread ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
issueGraphLivenessAutoRecoveryLookbackHours: issueGraphLivenessAutoRecoveryLookbackHours:
@ -52,7 +51,6 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
return { return {
enableEnvironments: false, enableEnvironments: false,
enableIsolatedWorkspaces: false, enableIsolatedWorkspaces: false,
enableNewestFirstIssueThread: false,
autoRestartDevServerWhenIdle: false, autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: false, enableIssueGraphLivenessAutoRecovery: false,
issueGraphLivenessAutoRecoveryLookbackHours: issueGraphLivenessAutoRecoveryLookbackHours:

View file

@ -295,19 +295,16 @@ function createFileDragEvent(type: string, files: File[]) {
describe("IssueChatThread", () => { describe("IssueChatThread", () => {
let container: HTMLDivElement; let container: HTMLDivElement;
const originalDocumentElementScrollIntoView = document.documentElement.scrollIntoView;
beforeEach(() => { beforeEach(() => {
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
window.scrollTo = vi.fn(); window.scrollTo = vi.fn();
document.documentElement.scrollIntoView = vi.fn() as unknown as typeof document.documentElement.scrollIntoView;
localStorage.clear(); localStorage.clear();
}); });
afterEach(() => { afterEach(() => {
container.remove(); container.remove();
document.documentElement.scrollIntoView = originalDocumentElementScrollIntoView;
vi.useRealTimers(); vi.useRealTimers();
appendMock.mockReset(); appendMock.mockReset();
markdownEditorFocusMock.mockReset(); markdownEditorFocusMock.mockReset();
@ -330,7 +327,6 @@ describe("IssueChatThread", () => {
liveRuns={[]} liveRuns={[]}
onAdd={async () => {}} onAdd={async () => {}}
showComposer={false} showComposer={false}
newestFirst
enableLiveTranscriptPolling={false} enableLiveTranscriptPolling={false}
/> />
</MemoryRouter>, </MemoryRouter>,
@ -340,16 +336,6 @@ describe("IssueChatThread", () => {
expect(container.textContent).toContain("Jump to latest"); expect(container.textContent).toContain("Jump to latest");
expect(container.textContent).not.toContain("Chat ("); 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; const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null;
expect(viewport).not.toBeNull(); expect(viewport).not.toBeNull();
expect(viewport?.className).not.toContain("overflow-y-auto"); expect(viewport?.className).not.toContain("overflow-y-auto");
@ -360,106 +346,6 @@ describe("IssueChatThread", () => {
}); });
}); });
it("defaults to oldest-first rendering and jump placement", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[
{
id: "comment-older",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: "agent-1",
authorUserId: null,
body: "Older comment",
authorType: "agent",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
},
{
id: "comment-newer",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: "agent-1",
authorUserId: null,
body: "Newer comment",
authorType: "agent",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
},
]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
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(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
newestFirst={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
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", () => { it("renders the composer in planning mode when the issue is in planning mode", () => {
const root = createRoot(container); const root = createRoot(container);
@ -1073,7 +959,6 @@ describe("IssueChatThread", () => {
agentMap={issueChatLongThreadAgentMap} agentMap={issueChatLongThreadAgentMap}
currentUserId="user-board" currentUserId="user-board"
onAdd={async () => {}} onAdd={async () => {}}
newestFirst
enableLiveTranscriptPolling={false} enableLiveTranscriptPolling={false}
onRefreshLatestComments={async () => { onRefreshLatestComments={async () => {
setComments([olderComment, latestComment]); setComments([olderComment, latestComment]);
@ -1110,7 +995,7 @@ describe("IssueChatThread", () => {
}); });
}); });
it("findLatestCommentMessageIndex prefers the first comment-anchored row when newest renders first", () => { it("findLatestCommentMessageIndex prefers the last comment-anchored row (PAP-2672)", () => {
const messages = [ const messages = [
{ metadata: { custom: { anchorId: "comment-a" } } }, { metadata: { custom: { anchorId: "comment-a" } } },
{ metadata: { custom: { anchorId: "run-1" } } }, { metadata: { custom: { anchorId: "run-1" } } },
@ -1118,7 +1003,7 @@ describe("IssueChatThread", () => {
{ metadata: { custom: { anchorId: "run-2" } } }, { metadata: { custom: { anchorId: "run-2" } } },
{ metadata: { custom: { anchorId: "activity-3" } } }, { metadata: { custom: { anchorId: "activity-3" } } },
]; ];
expect(findLatestCommentMessageIndex(messages as never)).toBe(0); expect(findLatestCommentMessageIndex(messages as never)).toBe(2);
expect( expect(
findLatestCommentMessageIndex([ findLatestCommentMessageIndex([
{ metadata: { custom: { anchorId: "run-only" } } }, { metadata: { custom: { anchorId: "run-only" } } },
@ -1127,17 +1012,6 @@ describe("IssueChatThread", () => {
expect(findLatestCommentMessageIndex([] as never)).toBe(-1); 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", () => { it("keeps the direct render path for short threads under the virtualization threshold", () => {
const root = createRoot(container); const root = createRoot(container);
const directComments = issueChatLongThreadComments.slice(0, 12); const directComments = issueChatLongThreadComments.slice(0, 12);
@ -1846,50 +1720,6 @@ 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(
<MemoryRouter>
<IssueChatThread
comments={[{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: "agent-1",
authorUserId: null,
body: "Agent summary",
authorType: "agent",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
}]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
newestFirst
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
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", () => { it("shows deferred wake badge only for hold-deferred queued comments", () => {
const root = createRoot(container); const root = createRoot(container);

View file

@ -138,7 +138,6 @@ import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice";
interface IssueChatMessageContext { interface IssueChatMessageContext {
feedbackDataSharingPreference: FeedbackDataSharingPreference; feedbackDataSharingPreference: FeedbackDataSharingPreference;
feedbackTermsUrl: string | null; feedbackTermsUrl: string | null;
newestFirst: boolean;
agentMap?: Map<string, Agent>; agentMap?: Map<string, Agent>;
currentUserId?: string | null; currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null; userLabelMap?: ReadonlyMap<string, string> | null;
@ -177,7 +176,6 @@ interface IssueChatMessageContext {
const IssueChatCtx = createContext<IssueChatMessageContext>({ const IssueChatCtx = createContext<IssueChatMessageContext>({
feedbackDataSharingPreference: "prompt", feedbackDataSharingPreference: "prompt",
feedbackTermsUrl: null, feedbackTermsUrl: null,
newestFirst: true,
issueStatus: undefined, issueStatus: undefined,
successfulRunHandoff: null, successfulRunHandoff: null,
}); });
@ -333,7 +331,6 @@ interface IssueChatThreadProps {
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void; onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
showComposer?: boolean; showComposer?: boolean;
showJumpToLatest?: boolean; showJumpToLatest?: boolean;
newestFirst?: boolean;
emptyMessage?: string; emptyMessage?: string;
variant?: "full" | "embedded"; variant?: "full" | "embedded";
enableLiveTranscriptPolling?: boolean; enableLiveTranscriptPolling?: boolean;
@ -533,6 +530,7 @@ function IssueChatFallbackThread({
const DRAFT_DEBOUNCE_MS = 800; const DRAFT_DEBOUNCE_MS = 800;
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96; const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
const SUBMIT_SCROLL_RESERVE_VH = 0.4;
type ComposerAttachmentItem = { type ComposerAttachmentItem = {
id: string; id: string;
@ -621,33 +619,6 @@ function commentDateLabel(date: Date | string | undefined): string {
return formatShortDate(date); return formatShortDate(date);
} }
function IssueChatTimestampLink({
anchorId,
createdAt,
className,
}: {
anchorId?: string;
createdAt?: Date | string;
className?: string;
}) {
if (!createdAt) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<a
href={anchorId ? `#${anchorId}` : undefined}
className={cn("text-[11px] text-muted-foreground hover:text-foreground hover:underline", className)}
>
{commentDateLabel(createdAt)}
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{formatDateTime(createdAt)}
</TooltipContent>
</Tooltip>
);
}
const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) { const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
const { onImageClick } = useContext(IssueChatCtx); const { onImageClick } = useContext(IssueChatCtx);
if (isSuccessfulRunHandoffComment(text)) { if (isSuccessfulRunHandoffComment(text)) {
@ -1288,7 +1259,6 @@ function IssueChatUserMessage({
onCancelQueued, onCancelQueued,
currentUserId, currentUserId,
userProfileMap, userProfileMap,
newestFirst,
} = useContext(IssueChatCtx); } = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>; const custom = message.metadata.custom as Record<string, unknown>;
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
@ -1320,20 +1290,13 @@ function IssueChatUserMessage({
); );
const messageBody = ( const messageBody = (
<div className={cn("flex min-w-0 max-w-[85%] flex-col", isCurrentUser && "items-end")}> <div className={cn("flex min-w-0 max-w-[85%] flex-col", isCurrentUser && "items-end")}>
<div className={cn("mb-1 flex w-full items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}> <div className={cn("mb-1 flex items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}>
<span className="text-sm font-medium text-foreground">{resolvedAuthorName}</span> <span className="text-sm font-medium text-foreground">{resolvedAuthorName}</span>
{followUpRequested ? ( {followUpRequested ? (
<Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]"> <Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]">
Follow-up Follow-up
</Badge> </Badge>
) : null} ) : null}
{newestFirst ? (
<IssueChatTimestampLink
anchorId={anchorId}
createdAt={message.createdAt}
className="shrink-0"
/>
) : null}
</div> </div>
<div <div
className={cn( className={cn(
@ -1388,12 +1351,19 @@ function IssueChatUserMessage({
isCurrentUser ? "justify-end" : "justify-start", isCurrentUser ? "justify-end" : "justify-start",
)} )}
> >
{!newestFirst ? ( <Tooltip>
<IssueChatTimestampLink <TooltipTrigger asChild>
anchorId={anchorId} <a
createdAt={message.createdAt} href={anchorId ? `#${anchorId}` : undefined}
/> className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
) : null} >
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{message.createdAt ? formatDateTime(message.createdAt) : ""}
</TooltipContent>
</Tooltip>
<button <button
type="button" type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground" className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
@ -1453,7 +1423,6 @@ function IssueChatAssistantMessage({
onVote, onVote,
agentMap, agentMap,
onStopRun, onStopRun,
newestFirst,
stopRunLabel = "Stop run", stopRunLabel = "Stop run",
stoppingRunLabel = "Stopping...", stoppingRunLabel = "Stopping...",
stopRunVariant = "stop", stopRunVariant = "stop",
@ -1544,27 +1513,18 @@ function IssueChatAssistantMessage({
</span> </span>
</button> </button>
) : ( ) : (
<div className="mb-1.5 flex items-center justify-between gap-3"> <div className="mb-1.5 flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2"> <span className="text-sm font-medium text-foreground">{authorName}</span>
<span className="text-sm font-medium text-foreground">{authorName}</span> {followUpRequested ? (
{followUpRequested ? ( <Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]">
<Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]"> Follow-up
Follow-up </Badge>
</Badge> ) : null}
) : null} {isRunning ? (
{isRunning ? ( <span className="inline-flex items-center gap-1 rounded-full border border-cyan-400/40 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-700 dark:text-cyan-200">
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-400/40 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-700 dark:text-cyan-200"> <Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="h-3 w-3 animate-spin" /> Running
Running </span>
</span>
) : null}
</div>
{newestFirst ? (
<IssueChatTimestampLink
anchorId={anchorId}
createdAt={message.createdAt}
className="shrink-0"
/>
) : null} ) : null}
</div> </div>
)} )}
@ -1622,12 +1582,19 @@ function IssueChatAssistantMessage({
onVote={handleVote} onVote={handleVote}
/> />
) : null} ) : null}
{!newestFirst ? ( <Tooltip>
<IssueChatTimestampLink <TooltipTrigger asChild>
anchorId={anchorId} <a
createdAt={message.createdAt} href={anchorId ? `#${anchorId}` : undefined}
/> className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
) : null} >
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{message.createdAt ? formatDateTime(message.createdAt) : ""}
</TooltipContent>
</Tooltip>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@ -2672,32 +2639,7 @@ function findMessageAnchorIndex(messages: readonly ThreadMessage[], anchorId: st
return messages.findIndex((message) => issueChatMessageAnchorId(message) === anchorId); return messages.findIndex((message) => issueChatMessageAnchorId(message) === anchorId);
} }
function findLatestMessageByRole( export function findLatestCommentMessageIndex(messages: readonly ThreadMessage[]): number {
messages: readonly ThreadMessage[],
role: ThreadMessage["role"],
newestFirst: boolean,
): ThreadMessage | undefined {
if (newestFirst) {
return messages.find((message) => message.role === role);
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index]?.role === role) return messages[index];
}
return undefined;
}
export function findLatestCommentMessageIndex(
messages: readonly ThreadMessage[],
newestFirst = true,
): number {
if (newestFirst) {
for (let index = 0; index < messages.length; index += 1) {
const anchorId = issueChatMessageAnchorId(messages[index]);
if (anchorId && anchorId.startsWith("comment-")) return index;
}
return -1;
}
for (let index = messages.length - 1; index >= 0; index -= 1) { for (let index = messages.length - 1; index >= 0; index -= 1) {
const anchorId = issueChatMessageAnchorId(messages[index]); const anchorId = issueChatMessageAnchorId(messages[index]);
if (anchorId && anchorId.startsWith("comment-")) return index; if (anchorId && anchorId.startsWith("comment-")) return index;
@ -3693,7 +3635,6 @@ export function IssueChatThread({
composerHint = null, composerHint = null,
showComposer = true, showComposer = true,
showJumpToLatest, showJumpToLatest,
newestFirst = false,
emptyMessage, emptyMessage,
variant = "full", variant = "full",
enableLiveTranscriptPolling = true, enableLiveTranscriptPolling = true,
@ -3720,14 +3661,17 @@ export function IssueChatThread({
const location = useLocation(); const location = useLocation();
const lastScrolledHashRef = useRef<string | null>(null); const lastScrolledHashRef = useRef<string | null>(null);
const virtualizedThreadRef = useRef<VirtualizedIssueChatThreadListHandle | null>(null); const virtualizedThreadRef = useRef<VirtualizedIssueChatThreadListHandle | null>(null);
const topAnchorRef = useRef<HTMLDivElement | null>(null); const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
const composerViewportAnchorRef = useRef<HTMLDivElement | null>(null); const composerViewportAnchorRef = useRef<HTMLDivElement | null>(null);
const composerViewportSnapshotRef = useRef<ReturnType<typeof captureComposerViewportSnapshot>>(null); const composerViewportSnapshotRef = useRef<ReturnType<typeof captureComposerViewportSnapshot>>(null);
const preserveComposerViewportRef = useRef(false); const preserveComposerViewportRef = useRef(false);
const pendingSubmitScrollRef = useRef(false); const pendingSubmitScrollRef = useRef(false);
const lastUserMessageIdRef = useRef<string | null>(null); const lastUserMessageIdRef = useRef<string | null>(null);
const spacerBaselineAnchorRef = useRef<string | null>(null);
const spacerInitialReserveRef = useRef(0);
const latestSettleTimeoutsRef = useRef<number[]>([]); const latestSettleTimeoutsRef = useRef<number[]>([]);
const latestSettleCleanupRef = useRef<(() => void) | null>(null); const latestSettleCleanupRef = useRef<(() => void) | null>(null);
const [bottomSpacerHeight, setBottomSpacerHeight] = useState(0);
const displayLiveRuns = useMemo(() => { const displayLiveRuns = useMemo(() => {
const deduped = new Map<string, LiveRunForIssue>(); const deduped = new Map<string, LiveRunForIssue>();
for (const run of liveRuns) { for (const run of liveRuns) {
@ -3821,7 +3765,7 @@ export function IssueChatThread({
); );
const stableMessagesRef = useRef<readonly ThreadMessage[]>([]); const stableMessagesRef = useRef<readonly ThreadMessage[]>([]);
const stableMessageCacheRef = useRef<Map<string, StableThreadMessageCacheEntry>>(new Map()); const stableMessageCacheRef = useRef<Map<string, StableThreadMessageCacheEntry>>(new Map());
const ascendingMessages = useMemo(() => { const messages = useMemo(() => {
const stabilized = stabilizeThreadMessages( const stabilized = stabilizeThreadMessages(
rawMessages, rawMessages,
stableMessagesRef.current, stableMessagesRef.current,
@ -3831,10 +3775,6 @@ export function IssueChatThread({
stableMessageCacheRef.current = stabilized.cache; stableMessageCacheRef.current = stabilized.cache;
return stabilized.messages; return stabilized.messages;
}, [rawMessages]); }, [rawMessages]);
const messages = useMemo(
() => newestFirst ? [...ascendingMessages].reverse() : ascendingMessages,
[ascendingMessages, newestFirst],
);
const latestMessagesRef = useRef<readonly ThreadMessage[]>(messages); const latestMessagesRef = useRef<readonly ThreadMessage[]>(messages);
latestMessagesRef.current = messages; latestMessagesRef.current = messages;
@ -3909,26 +3849,48 @@ export function IssueChatThread({
}); });
useEffect(() => { useEffect(() => {
const latestUserMessage = findLatestMessageByRole(messages, "user", newestFirst); const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
const latestUserId = latestUserMessage?.id ?? null; const lastUserId = lastUserMessage?.id ?? null;
if ( if (
pendingSubmitScrollRef.current pendingSubmitScrollRef.current
&& latestUserId && lastUserId
&& latestUserId !== lastUserMessageIdRef.current && lastUserId !== lastUserMessageIdRef.current
) { ) {
pendingSubmitScrollRef.current = false; pendingSubmitScrollRef.current = false;
const custom = latestUserMessage?.metadata.custom as { anchorId?: unknown } | undefined; const custom = lastUserMessage?.metadata.custom as { anchorId?: unknown } | undefined;
const anchorId = typeof custom?.anchorId === "string" ? custom.anchorId : null; const anchorId = typeof custom?.anchorId === "string" ? custom.anchorId : null;
if (anchorId) { if (anchorId) {
const reserve = Math.round(window.innerHeight * SUBMIT_SCROLL_RESERVE_VH);
spacerBaselineAnchorRef.current = anchorId;
spacerInitialReserveRef.current = reserve;
setBottomSpacerHeight(reserve);
requestAnimationFrame(() => { requestAnimationFrame(() => {
scrollToThreadAnchor(anchorId, { align: "start", behavior: "smooth" }); scrollToThreadAnchor(anchorId, { align: "start", behavior: "smooth" });
}); });
} }
} }
lastUserMessageIdRef.current = latestUserId; lastUserMessageIdRef.current = lastUserId;
}, [messageAnchorIndex, messages, newestFirst, useVirtualizedThread]); }, [messageAnchorIndex, messages, useVirtualizedThread]);
useLayoutEffect(() => {
const anchorId = spacerBaselineAnchorRef.current;
if (!anchorId || spacerInitialReserveRef.current <= 0) return;
const userEl = document.getElementById(anchorId);
const bottomEl = bottomAnchorRef.current;
if (!userEl || !bottomEl) return;
const contentBelow = Math.max(
0,
bottomEl.getBoundingClientRect().top - userEl.getBoundingClientRect().bottom,
);
const next = Math.max(0, spacerInitialReserveRef.current - contentBelow);
setBottomSpacerHeight((prev) => (prev === next ? prev : next));
if (next === 0) {
spacerBaselineAnchorRef.current = null;
spacerInitialReserveRef.current = 0;
}
}, [messages]);
useLayoutEffect(() => { useLayoutEffect(() => {
const composerElement = composerViewportAnchorRef.current; const composerElement = composerViewportAnchorRef.current;
if (preserveComposerViewportRef.current) { if (preserveComposerViewportRef.current) {
@ -3976,33 +3938,26 @@ export function IssueChatThread({
function jumpToLatestFallback() { function jumpToLatestFallback() {
if (useVirtualizedThread) { if (useVirtualizedThread) {
if (newestFirst) { virtualizedThreadRef.current?.scrollToLatest({ behavior: "smooth" });
virtualizedThreadRef.current?.scrollToIndex(0, { align: "start", behavior: "smooth" });
} else {
virtualizedThreadRef.current?.scrollToLatest({ behavior: "smooth" });
}
return; return;
} }
if (newestFirst) { bottomAnchorRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
topAnchorRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
document.documentElement.scrollIntoView({ behavior: "smooth", block: "end" });
} }
// Lands on the latest `comment-*` row and then keeps nudging the scroll // Lands on the latest `comment-*` row and then drives the scroll the rest
// until that newest comment is pinned to the top edge as row measurements // of the way home as the virtualizer's per-row measurements arrive.
// settle in the virtualizer.
// //
// The virtualizer estimates 220px for unmeasured rows. On long threads // The virtualizer estimates 220px for unmeasured rows. On long threads
// with tall markdown comments (PAP-2536 et al.), totalSize is hugely // with tall markdown comments (PAP-2536 et al.), totalSize is hugely
// underestimated until rows render and get measured. A single scroll can // underestimated until rows render and get measured. A single scroll
// land below the true newest row; rendered rows then expand and shift. The // lands above the actual bottom; rendered rows then expand, the layout
// convergence loop below keeps issuing `scrollIntoView` until the newest // grows, and the user has to keep clicking Jump-to-latest to walk closer
// comment element is at the scroll container's top edge (or the layout // to the real bottom. The convergence loop below issues `scrollIntoView`
// stops changing). // on the latest comment element on every tick until the DOM bottom of
// that element is at the scroll container's bottom (or scroll position
// and content height stop changing).
function scrollToLatestCommentWithSettle(messageSnapshot: readonly ThreadMessage[] = latestMessagesRef.current) { function scrollToLatestCommentWithSettle(messageSnapshot: readonly ThreadMessage[] = latestMessagesRef.current) {
const latestCommentIndex = findLatestCommentMessageIndex(messageSnapshot, newestFirst); const latestCommentIndex = findLatestCommentMessageIndex(messageSnapshot);
if (latestCommentIndex < 0) { if (latestCommentIndex < 0) {
jumpToLatestFallback(); jumpToLatestFallback();
return; return;
@ -4015,7 +3970,7 @@ export function IssueChatThread({
const initial = scrollToThreadAnchor( const initial = scrollToThreadAnchor(
latestCommentAnchor, latestCommentAnchor,
{ align: "start", behavior: "smooth" }, { align: "end", behavior: "smooth" },
messageSnapshot, messageSnapshot,
); );
if (!initial) { if (!initial) {
@ -4087,7 +4042,7 @@ export function IssueChatThread({
// Row hasn't been rendered into the virtualizer's buffer yet — nudge // Row hasn't been rendered into the virtualizer's buffer yet — nudge
// the offset (instant) so it gets mounted, then keep settling. // the offset (instant) so it gets mounted, then keep settling.
virtualizedThreadRef.current?.scrollToIndex(latestCommentIndex, { virtualizedThreadRef.current?.scrollToIndex(latestCommentIndex, {
align: "start", align: "end",
behavior: "auto", behavior: "auto",
}); });
scheduleTick(TICK_MS); scheduleTick(TICK_MS);
@ -4095,22 +4050,22 @@ export function IssueChatThread({
} }
const container = resolveScrollContainer(); const container = resolveScrollContainer();
const containerTop = container const containerBottom = container
? container.getBoundingClientRect().top ? container.getBoundingClientRect().bottom
: 0; : window.innerHeight;
const elTop = el.getBoundingClientRect().top; const elBottom = el.getBoundingClientRect().bottom;
const offTop = elTop - containerTop; const offBottom = elBottom - containerBottom;
if (Math.abs(offTop) > TOLERANCE_PX) { if (Math.abs(offBottom) > TOLERANCE_PX) {
el.scrollIntoView({ behavior: "smooth", block: "start" }); el.scrollIntoView({ behavior: "smooth", block: "end" });
} }
const currentScrollTop = container?.scrollTop ?? window.scrollY; const currentScrollTop = container?.scrollTop ?? window.scrollY;
const currentScrollHeight = container?.scrollHeight ?? document.documentElement.scrollHeight; const currentScrollHeight = container?.scrollHeight ?? document.documentElement.scrollHeight;
const scrollStable = Math.abs(currentScrollTop - lastScrollTop) < 1; const scrollStable = Math.abs(currentScrollTop - lastScrollTop) < 1;
const heightStable = currentScrollHeight === lastScrollHeight; const heightStable = currentScrollHeight === lastScrollHeight;
const atTop = Math.abs(offTop) <= TOLERANCE_PX; const atBottom = Math.abs(offBottom) <= TOLERANCE_PX;
if (scrollStable && heightStable && atTop) { if (scrollStable && heightStable && atBottom) {
stableTicks += 1; stableTicks += 1;
if (stableTicks >= 3) { if (stableTicks >= 3) {
finish(); finish();
@ -4163,7 +4118,6 @@ export function IssueChatThread({
() => ({ () => ({
feedbackDataSharingPreference, feedbackDataSharingPreference,
feedbackTermsUrl, feedbackTermsUrl,
newestFirst,
agentMap, agentMap,
currentUserId, currentUserId,
userLabelMap, userLabelMap,
@ -4186,7 +4140,6 @@ export function IssueChatThread({
[ [
feedbackDataSharingPreference, feedbackDataSharingPreference,
feedbackTermsUrl, feedbackTermsUrl,
newestFirst,
agentMap, agentMap,
currentUserId, currentUserId,
userLabelMap, userLabelMap,
@ -4225,7 +4178,7 @@ export function IssueChatThread({
<AssistantRuntimeProvider runtime={runtime}> <AssistantRuntimeProvider runtime={runtime}>
<IssueChatCtx.Provider value={chatCtx}> <IssueChatCtx.Provider value={chatCtx}>
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}> <div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
{resolvedShowJumpToLatest && !newestFirst ? ( {resolvedShowJumpToLatest ? (
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
type="button" type="button"
@ -4236,6 +4189,7 @@ export function IssueChatThread({
</button> </button>
</div> </div>
) : null} ) : null}
<IssueChatErrorBoundary <IssueChatErrorBoundary
resetKey={errorBoundaryResetKey} resetKey={errorBoundaryResetKey}
messages={messages} messages={messages}
@ -4247,7 +4201,6 @@ export function IssueChatThread({
data-testid="thread-viewport" data-testid="thread-viewport"
className={variant === "embedded" ? "space-y-3" : "space-y-4"} className={variant === "embedded" ? "space-y-3" : "space-y-4"}
> >
<div ref={topAnchorRef} />
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className={cn( <div className={cn(
"text-center text-sm text-muted-foreground", "text-center text-sm text-muted-foreground",
@ -4280,8 +4233,8 @@ export function IssueChatThread({
stoppingRunId={stoppingRunId} stoppingRunId={stoppingRunId}
interruptingQueuedRunId={interruptingQueuedRunId} interruptingQueuedRunId={interruptingQueuedRunId}
/> />
)) ))
)} )}
{showComposer ? ( {showComposer ? (
<div data-testid="issue-chat-thread-notices" className="space-y-2"> <div data-testid="issue-chat-thread-notices" className="space-y-2">
<IssueAssignedBacklogNotice <IssueAssignedBacklogNotice
@ -4305,29 +4258,18 @@ export function IssueChatThread({
<IssueAssigneePausedNotice agent={assignedAgent} /> <IssueAssigneePausedNotice agent={assignedAgent} />
</div> </div>
) : null} ) : null}
<div ref={bottomAnchorRef} />
{showComposer ? ( {showComposer ? (
<div <div
aria-hidden aria-hidden
data-testid="issue-chat-bottom-spacer" data-testid="issue-chat-bottom-spacer"
style={{ height: 0 }} style={{ height: bottomSpacerHeight }}
/> />
) : null} ) : null}
</div> </div>
</div> </div>
</IssueChatErrorBoundary> </IssueChatErrorBoundary>
{resolvedShowJumpToLatest && newestFirst ? (
<div className="flex justify-end">
<button
type="button"
onClick={handleJumpToLatest}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Jump to latest
</button>
</div>
) : null}
{showComposer ? ( {showComposer ? (
<div <div
ref={composerViewportAnchorRef} ref={composerViewportAnchorRef}

View file

@ -205,7 +205,6 @@ export function InstanceExperimentalSettings() {
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true; const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true; const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const enableNewestFirstIssueThread = experimentalQuery.data?.enableNewestFirstIssueThread === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true; const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
const enableIssueGraphLivenessAutoRecovery = const enableIssueGraphLivenessAutoRecovery =
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true; experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
@ -299,25 +298,6 @@ export function InstanceExperimentalSettings() {
</div> </div>
</section> </section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Enable Newest-First Issue Thread</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
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.
</p>
</div>
<ToggleSwitch
checked={enableNewestFirstIssueThread}
onCheckedChange={() =>
toggleMutation.mutate({ enableNewestFirstIssueThread: !enableNewestFirstIssueThread })}
disabled={toggleMutation.isPending}
aria-label="Toggle newest-first issue thread experimental setting"
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5"> <section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">

View file

@ -59,7 +59,6 @@ const mockProjectsApi = vi.hoisted(() => ({
const mockInstanceSettingsApi = vi.hoisted(() => ({ const mockInstanceSettingsApi = vi.hoisted(() => ({
getGeneral: vi.fn(), getGeneral: vi.fn(),
getExperimental: vi.fn(),
})); }));
const mockNavigate = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn());
@ -193,7 +192,6 @@ vi.mock("../components/InlineEditor", () => ({
vi.mock("../components/IssueChatThread", () => ({ vi.mock("../components/IssueChatThread", () => ({
IssueChatThread: (props: { IssueChatThread: (props: {
newestFirst?: boolean;
onWorkModeChange?: (workMode: string) => void; onWorkModeChange?: (workMode: string) => void;
issueWorkMode?: string; issueWorkMode?: string;
onStopRun?: (runId: string) => Promise<void>; onStopRun?: (runId: string) => Promise<void>;
@ -806,9 +804,6 @@ describe("IssueDetail", () => {
keyboardShortcuts: false, keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt", feedbackDataSharingPreference: "prompt",
}); });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableNewestFirstIssueThread: false,
});
mockIssuesListRender.mockClear(); mockIssuesListRender.mockClear();
mockIssueChatThreadRender.mockClear(); mockIssueChatThreadRender.mockClear();
}); });
@ -844,45 +839,6 @@ describe("IssueDetail", () => {
expect(consoleErrorSpy).not.toHaveBeenCalled(); 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(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
newestFirst: true,
});
});
it("passes blocker attention to the issue detail header status icon", async () => { it("passes blocker attention to the issue detail header status icon", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({ mockIssuesApi.get.mockResolvedValue(createIssue({
status: "blocked", status: "blocked",

View file

@ -592,7 +592,6 @@ type IssueDetailChatTabProps = {
issueId: string; issueId: string;
companyId: string; companyId: string;
projectId: string | null; projectId: string | null;
newestFirstIssueThreadEnabled: boolean;
issueStatus: Issue["status"]; issueStatus: Issue["status"];
issueWorkMode: IssueWorkMode; issueWorkMode: IssueWorkMode;
executionRunId: string | null; executionRunId: string | null;
@ -656,7 +655,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
issueId, issueId,
companyId, companyId,
projectId, projectId,
newestFirstIssueThreadEnabled,
issueWorkMode, issueWorkMode,
issueStatus, issueStatus,
executionRunId, executionRunId,
@ -857,7 +855,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
) : null} ) : null}
<IssueChatThread <IssueChatThread
composerRef={composerRef} composerRef={composerRef}
newestFirst={newestFirstIssueThreadEnabled}
comments={commentsWithRunMeta} comments={commentsWithRunMeta}
interactions={interactions} interactions={interactions}
feedbackVotes={feedbackVotes} feedbackVotes={feedbackVotes}
@ -1318,12 +1315,6 @@ export function IssueDetail() {
}); });
const resolvedHasActiveRun = issue ? shouldTrackIssueActiveRun(issue) && hasActiveRun : hasActiveRun; const resolvedHasActiveRun = issue ? shouldTrackIssueActiveRun(issue) && hasActiveRun : hasActiveRun;
const hasLiveRuns = liveRunCount > 0 || resolvedHasActiveRun; const hasLiveRuns = liveRunCount > 0 || resolvedHasActiveRun;
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
placeholderData: keepPreviousDataForSameQueryTail(issueId ?? "pending"),
});
const newestFirstIssueThreadEnabled = experimentalSettings?.enableNewestFirstIssueThread === true;
useEffect(() => { useEffect(() => {
if (!hasLiveRuns && locallyQueuedCommentRunIds.size > 0) { if (!hasLiveRuns && locallyQueuedCommentRunIds.size > 0) {
setLocallyQueuedCommentRunIds(new Map()); setLocallyQueuedCommentRunIds(new Map());
@ -3790,7 +3781,6 @@ export function IssueDetail() {
issueId={issue.id} issueId={issue.id}
companyId={issue.companyId} companyId={issue.companyId}
projectId={issue.projectId ?? null} projectId={issue.projectId ?? null}
newestFirstIssueThreadEnabled={newestFirstIssueThreadEnabled}
issueStatus={issue.status} issueStatus={issue.status}
issueWorkMode={issue.workMode ?? "standard"} issueWorkMode={issue.workMode ?? "standard"}
executionRunId={issue.executionRunId ?? null} executionRunId={issue.executionRunId ?? null}