Skip to content

Commit a904eff

Browse files
authored
Add experimental newest-first issue thread (paperclipai#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
1 parent 4269545 commit a904eff

9 files changed

Lines changed: 416 additions & 107 deletions

File tree

packages/shared/src/types/instance.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface InstanceGeneralSettings {
2929
export interface InstanceExperimentalSettings {
3030
enableEnvironments: boolean;
3131
enableIsolatedWorkspaces: boolean;
32+
enableNewestFirstIssueThread: boolean;
3233
autoRestartDevServerWhenIdle: boolean;
3334
enableIssueGraphLivenessAutoRecovery: boolean;
3435
issueGraphLivenessAutoRecoveryLookbackHours: number;

packages/shared/src/validators/instance.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.
3838
export const instanceExperimentalSettingsSchema = z.object({
3939
enableEnvironments: z.boolean().default(false),
4040
enableIsolatedWorkspaces: z.boolean().default(false),
41+
enableNewestFirstIssueThread: z.boolean().default(false),
4142
autoRestartDevServerWhenIdle: z.boolean().default(false),
4243
enableIssueGraphLivenessAutoRecovery: z.boolean().default(false),
4344
issueGraphLivenessAutoRecoveryLookbackHours: z

server/src/__tests__/instance-settings-routes.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ describe("instance settings routes", () => {
6464
mockInstanceSettingsService.getExperimental.mockResolvedValue({
6565
enableEnvironments: false,
6666
enableIsolatedWorkspaces: false,
67+
enableNewestFirstIssueThread: false,
6768
autoRestartDevServerWhenIdle: false,
6869
enableIssueGraphLivenessAutoRecovery: true,
6970
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@@ -81,6 +82,7 @@ describe("instance settings routes", () => {
8182
experimental: {
8283
enableEnvironments: true,
8384
enableIsolatedWorkspaces: true,
85+
enableNewestFirstIssueThread: false,
8486
autoRestartDevServerWhenIdle: false,
8587
enableIssueGraphLivenessAutoRecovery: true,
8688
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@@ -123,6 +125,7 @@ describe("instance settings routes", () => {
123125
expect(getRes.body).toEqual({
124126
enableEnvironments: false,
125127
enableIsolatedWorkspaces: false,
128+
enableNewestFirstIssueThread: false,
126129
autoRestartDevServerWhenIdle: false,
127130
enableIssueGraphLivenessAutoRecovery: true,
128131
issueGraphLivenessAutoRecoveryLookbackHours: 24,

server/src/services/instance-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
4141
return {
4242
enableEnvironments: parsed.data.enableEnvironments ?? false,
4343
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
44+
enableNewestFirstIssueThread: parsed.data.enableNewestFirstIssueThread ?? false,
4445
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
4546
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
4647
issueGraphLivenessAutoRecoveryLookbackHours:
@@ -51,6 +52,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
5152
return {
5253
enableEnvironments: false,
5354
enableIsolatedWorkspaces: false,
55+
enableNewestFirstIssueThread: false,
5456
autoRestartDevServerWhenIdle: false,
5557
enableIssueGraphLivenessAutoRecovery: false,
5658
issueGraphLivenessAutoRecoveryLookbackHours:

ui/src/components/IssueChatThread.test.tsx

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,16 +295,19 @@ function createFileDragEvent(type: string, files: File[]) {
295295

296296
describe("IssueChatThread", () => {
297297
let container: HTMLDivElement;
298+
const originalDocumentElementScrollIntoView = document.documentElement.scrollIntoView;
298299

299300
beforeEach(() => {
300301
container = document.createElement("div");
301302
document.body.appendChild(container);
302303
window.scrollTo = vi.fn();
304+
document.documentElement.scrollIntoView = vi.fn() as unknown as typeof document.documentElement.scrollIntoView;
303305
localStorage.clear();
304306
});
305307

306308
afterEach(() => {
307309
container.remove();
310+
document.documentElement.scrollIntoView = originalDocumentElementScrollIntoView;
308311
vi.useRealTimers();
309312
appendMock.mockReset();
310313
markdownEditorFocusMock.mockReset();
@@ -327,6 +330,7 @@ describe("IssueChatThread", () => {
327330
liveRuns={[]}
328331
onAdd={async () => {}}
329332
showComposer={false}
333+
newestFirst
330334
enableLiveTranscriptPolling={false}
331335
/>
332336
</MemoryRouter>,
@@ -336,6 +340,16 @@ describe("IssueChatThread", () => {
336340
expect(container.textContent).toContain("Jump to latest");
337341
expect(container.textContent).not.toContain("Chat (");
338342

343+
const threadRoot = container.querySelector('[data-testid="thread-root"]');
344+
const jumpButton = Array.from(container.querySelectorAll("button")).find(
345+
(button) => button.textContent === "Jump to latest",
346+
);
347+
expect(threadRoot).not.toBeNull();
348+
expect(jumpButton).toBeDefined();
349+
expect(
350+
threadRoot?.compareDocumentPosition(jumpButton!),
351+
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
352+
339353
const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null;
340354
expect(viewport).not.toBeNull();
341355
expect(viewport?.className).not.toContain("overflow-y-auto");
@@ -346,6 +360,106 @@ describe("IssueChatThread", () => {
346360
});
347361
});
348362

363+
it("defaults to oldest-first rendering and jump placement", () => {
364+
const root = createRoot(container);
365+
366+
act(() => {
367+
root.render(
368+
<MemoryRouter>
369+
<IssueChatThread
370+
comments={[
371+
{
372+
id: "comment-older",
373+
companyId: "company-1",
374+
issueId: "issue-1",
375+
authorAgentId: "agent-1",
376+
authorUserId: null,
377+
body: "Older comment",
378+
authorType: "agent",
379+
presentation: null,
380+
metadata: null,
381+
createdAt: new Date("2026-04-06T12:00:00.000Z"),
382+
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
383+
},
384+
{
385+
id: "comment-newer",
386+
companyId: "company-1",
387+
issueId: "issue-1",
388+
authorAgentId: "agent-1",
389+
authorUserId: null,
390+
body: "Newer comment",
391+
authorType: "agent",
392+
presentation: null,
393+
metadata: null,
394+
createdAt: new Date("2026-04-06T12:01:00.000Z"),
395+
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
396+
},
397+
]}
398+
linkedRuns={[]}
399+
timelineEvents={[]}
400+
liveRuns={[]}
401+
onAdd={async () => {}}
402+
showComposer={false}
403+
enableLiveTranscriptPolling={false}
404+
/>
405+
</MemoryRouter>,
406+
);
407+
});
408+
409+
const rows = Array.from(container.querySelectorAll('[data-testid="issue-chat-message-row"]'));
410+
expect(rows[0]?.textContent).toContain("Older comment");
411+
expect(rows[1]?.textContent).toContain("Newer comment");
412+
413+
const threadRoot = container.querySelector('[data-testid="thread-root"]');
414+
const jumpButton = Array.from(container.querySelectorAll("button")).find(
415+
(button) => button.textContent === "Jump to latest",
416+
);
417+
expect(threadRoot).not.toBeNull();
418+
expect(jumpButton).toBeDefined();
419+
expect(
420+
threadRoot?.compareDocumentPosition(jumpButton!),
421+
).toBe(Node.DOCUMENT_POSITION_PRECEDING);
422+
423+
act(() => {
424+
root.unmount();
425+
});
426+
});
427+
428+
it("renders the jump control above the thread when newest-first mode is disabled", () => {
429+
const root = createRoot(container);
430+
431+
act(() => {
432+
root.render(
433+
<MemoryRouter>
434+
<IssueChatThread
435+
comments={[]}
436+
linkedRuns={[]}
437+
timelineEvents={[]}
438+
liveRuns={[]}
439+
onAdd={async () => {}}
440+
showComposer={false}
441+
newestFirst={false}
442+
enableLiveTranscriptPolling={false}
443+
/>
444+
</MemoryRouter>,
445+
);
446+
});
447+
448+
const threadRoot = container.querySelector('[data-testid="thread-root"]');
449+
const jumpButton = Array.from(container.querySelectorAll("button")).find(
450+
(button) => button.textContent === "Jump to latest",
451+
);
452+
expect(threadRoot).not.toBeNull();
453+
expect(jumpButton).toBeDefined();
454+
expect(
455+
threadRoot?.compareDocumentPosition(jumpButton!),
456+
).toBe(Node.DOCUMENT_POSITION_PRECEDING);
457+
458+
act(() => {
459+
root.unmount();
460+
});
461+
});
462+
349463
it("renders the composer in planning mode when the issue is in planning mode", () => {
350464
const root = createRoot(container);
351465

@@ -959,6 +1073,7 @@ describe("IssueChatThread", () => {
9591073
agentMap={issueChatLongThreadAgentMap}
9601074
currentUserId="user-board"
9611075
onAdd={async () => {}}
1076+
newestFirst
9621077
enableLiveTranscriptPolling={false}
9631078
onRefreshLatestComments={async () => {
9641079
setComments([olderComment, latestComment]);
@@ -995,15 +1110,15 @@ describe("IssueChatThread", () => {
9951110
});
9961111
});
9971112

998-
it("findLatestCommentMessageIndex prefers the last comment-anchored row (PAP-2672)", () => {
1113+
it("findLatestCommentMessageIndex prefers the first comment-anchored row when newest renders first", () => {
9991114
const messages = [
10001115
{ metadata: { custom: { anchorId: "comment-a" } } },
10011116
{ metadata: { custom: { anchorId: "run-1" } } },
10021117
{ metadata: { custom: { anchorId: "comment-b" } } },
10031118
{ metadata: { custom: { anchorId: "run-2" } } },
10041119
{ metadata: { custom: { anchorId: "activity-3" } } },
10051120
];
1006-
expect(findLatestCommentMessageIndex(messages as never)).toBe(2);
1121+
expect(findLatestCommentMessageIndex(messages as never)).toBe(0);
10071122
expect(
10081123
findLatestCommentMessageIndex([
10091124
{ metadata: { custom: { anchorId: "run-only" } } },
@@ -1012,6 +1127,17 @@ describe("IssueChatThread", () => {
10121127
expect(findLatestCommentMessageIndex([] as never)).toBe(-1);
10131128
});
10141129

1130+
it("findLatestCommentMessageIndex prefers the last comment-anchored row when newest-first mode is disabled", () => {
1131+
const messages = [
1132+
{ metadata: { custom: { anchorId: "comment-a" } } },
1133+
{ metadata: { custom: { anchorId: "run-1" } } },
1134+
{ metadata: { custom: { anchorId: "comment-b" } } },
1135+
{ metadata: { custom: { anchorId: "run-2" } } },
1136+
{ metadata: { custom: { anchorId: "activity-3" } } },
1137+
];
1138+
expect(findLatestCommentMessageIndex(messages as never, false)).toBe(2);
1139+
});
1140+
10151141
it("keeps the direct render path for short threads under the virtualization threshold", () => {
10161142
const root = createRoot(container);
10171143
const directComments = issueChatLongThreadComments.slice(0, 12);
@@ -1720,6 +1846,50 @@ describe("IssueChatThread", () => {
17201846
});
17211847
});
17221848

1849+
it("renders the comment timestamp above the comment body", () => {
1850+
vi.useFakeTimers();
1851+
vi.setSystemTime(new Date("2026-04-08T12:00:00.000Z"));
1852+
const root = createRoot(container);
1853+
1854+
act(() => {
1855+
root.render(
1856+
<MemoryRouter>
1857+
<IssueChatThread
1858+
comments={[{
1859+
id: "comment-1",
1860+
companyId: "company-1",
1861+
issueId: "issue-1",
1862+
authorAgentId: "agent-1",
1863+
authorUserId: null,
1864+
body: "Agent summary",
1865+
authorType: "agent",
1866+
presentation: null,
1867+
metadata: null,
1868+
createdAt: new Date("2026-04-06T12:00:00.000Z"),
1869+
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
1870+
}]}
1871+
linkedRuns={[]}
1872+
timelineEvents={[]}
1873+
liveRuns={[]}
1874+
onAdd={async () => {}}
1875+
showComposer={false}
1876+
newestFirst
1877+
enableLiveTranscriptPolling={false}
1878+
/>
1879+
</MemoryRouter>,
1880+
);
1881+
});
1882+
1883+
const text = container.textContent ?? "";
1884+
const timestampIndex = text.indexOf("2d ago");
1885+
expect(timestampIndex).toBeGreaterThanOrEqual(0);
1886+
expect(timestampIndex).toBeLessThan(text.indexOf("Agent summary"));
1887+
1888+
act(() => {
1889+
root.unmount();
1890+
});
1891+
});
1892+
17231893
it("shows deferred wake badge only for hold-deferred queued comments", () => {
17241894
const root = createRoot(container);
17251895

0 commit comments

Comments
 (0)