mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
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:
parent
4269545b19
commit
a904effb96
9 changed files with 415 additions and 106 deletions
|
|
@ -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}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
|
|
@ -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(
|
||||
<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", () => {
|
||||
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(
|
||||
<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", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue