mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +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
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue