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
This commit is contained in:
Devin Foley 2026-05-07 16:45:12 -07:00 committed by GitHub
parent 4269545b19
commit a904effb96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 415 additions and 106 deletions

View file

@ -205,6 +205,7 @@ export function InstanceExperimentalSettings() {
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const enableNewestFirstIssueThread = experimentalQuery.data?.enableNewestFirstIssueThread === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
const enableIssueGraphLivenessAutoRecovery =
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
@ -298,6 +299,25 @@ export function InstanceExperimentalSettings() {
</div>
</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">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">

View file

@ -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<void>;
@ -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(
<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 () => {
mockIssuesApi.get.mockResolvedValue(createIssue({
status: "blocked",

View file

@ -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}
<IssueChatThread
composerRef={composerRef}
newestFirst={newestFirstIssueThreadEnabled}
comments={commentsWithRunMeta}
interactions={interactions}
feedbackVotes={feedbackVotes}
@ -1315,6 +1318,12 @@ export function IssueDetail() {
});
const resolvedHasActiveRun = issue ? shouldTrackIssueActiveRun(issue) && hasActiveRun : hasActiveRun;
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(() => {
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}