[codex] Split PR #4692 UI/QoL updates (#4701)

## Thinking Path

> - Paperclip orchestrates AI agents through a company-scoped control
plane.
> - The affected surface is the board UI for issue threads, issue lists,
routines, dialogs, navigation, and issue review indicators.
> - Closed PR #4692 bundled backend, schema, docs, workflow, and UI/QoL
work into one oversized change set.
> - Greptile could not keep reviewing that broad PR because it exceeded
the 100-file review limit and mixed unrelated concerns.
> - This pull request extracts the UI/QoL slice into a fresh branch
under the review limit while leaving workflow and lockfile churn out.
> - The benefit is a focused review path for the board UI performance
and workflow improvements without reopening the oversized PR.

## What Changed

- Added long issue-thread virtualization, scroll-container binding,
anchor preservation, latest-comment jump targeting, and related
regression/perf fixtures.
- Improved issue list scalability with scroll-based loading, server
offset parameters, and pagination-focused UI tests.
- Reduced new issue dialog typing churn and split dialog action
subscriptions so broad layout/nav surfaces avoid unnecessary renders.
- Added routine variables help and routine description mention options
for users, agents, and projects.
- Added productivity review badge/link UI and fixed the badge to use
Paperclip's company-prefixed router link.
- Kept the split PR below Greptile's review limit and excluded
`.github/workflows/pr.yml` and `pnpm-lock.yaml`.

## Verification

- `pnpm install --no-frozen-lockfile` in the clean worktree to install
`@tanstack/react-virtual` locally without committing lockfile churn.
- `pnpm --filter @paperclipai/ui exec vitest run --config
vitest.config.ts src/components/IssueChatThread.test.tsx
src/components/IssuesList.test.tsx
src/components/NewIssueDialog.test.tsx src/pages/Routines.test.tsx
src/pages/Issues.test.tsx` passed: 5 files, 83 tests.
- `pnpm --filter @paperclipai/ui typecheck` passed.
- `git diff --check origin/master..HEAD` passed.
- Split-scope checks: 53 changed files; no `.github/workflows/pr.yml`;
no `pnpm-lock.yaml`.
- Screenshots were not captured in this heartbeat; the changes are
primarily virtualization, routing, pagination, and editor behavior
covered by focused regression tests.

## Risks

- Moderate UI risk because issue-thread virtualization changes scroll
behavior on long conversations; regression tests cover anchor jumps,
latest-comment targeting, row metadata, and short-thread fallback.
- Moderate integration risk because the issue-list offset parameter and
productivity review field depend on matching API behavior.
- Dependency risk: the UI package adds `@tanstack/react-virtual` while
repository policy keeps `pnpm-lock.yaml` out of PRs, so CI must resolve
dependency changes through the repo's normal lockfile policy.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled local repository and
GitHub workflow. Exact runtime context window was not exposed by the
harness.

## 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
- [x] 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-04-28 17:18:58 -05:00 committed by GitHub
parent 1991ec9d6f
commit 6b7f6ce4b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 3388 additions and 260 deletions

View file

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
issueChatLongThreadAgentMap,
issueChatLongThreadComments,
issueChatLongThreadEvents,
issueChatLongThreadLinkedRuns,
issueChatLongThreadMarkdownCommentIds,
issueChatLongThreadTranscriptsByRunId,
LONG_THREAD_COMMENT_COUNT,
LONG_THREAD_MARKDOWN_COMMENT_COUNT,
} from "./issueChatLongThreadFixture";
import { buildIssueChatMessages } from "../lib/issue-chat-messages";
describe("issueChatLongThreadFixture", () => {
it("builds a deterministic long issue-thread shape", () => {
const messages = buildIssueChatMessages({
comments: issueChatLongThreadComments,
timelineEvents: issueChatLongThreadEvents,
linkedRuns: issueChatLongThreadLinkedRuns,
liveRuns: [],
agentMap: issueChatLongThreadAgentMap,
currentUserId: "user-board",
transcriptsByRunId: issueChatLongThreadTranscriptsByRunId,
hasOutputForRun: (runId) => issueChatLongThreadTranscriptsByRunId.has(runId),
});
expect(issueChatLongThreadComments).toHaveLength(LONG_THREAD_COMMENT_COUNT);
expect(issueChatLongThreadMarkdownCommentIds.size).toBe(LONG_THREAD_MARKDOWN_COMMENT_COUNT);
expect(messages.length).toBeGreaterThanOrEqual(450);
expect(messages.filter((message) => message.role === "assistant").length).toBeGreaterThanOrEqual(
LONG_THREAD_MARKDOWN_COMMENT_COUNT,
);
});
it("keeps markdown rows markdown-heavy enough to exercise MarkdownBody", () => {
const markdownComments = issueChatLongThreadComments.filter((comment) =>
issueChatLongThreadMarkdownCommentIds.has(comment.id),
);
expect(markdownComments).toHaveLength(LONG_THREAD_MARKDOWN_COMMENT_COUNT);
for (const comment of markdownComments.slice(0, 5)) {
expect(comment.body).toContain("## Baseline note");
expect(comment.body).toContain("```ts");
expect(comment.body).toContain("| Metric | Value |");
}
});
});

View file

@ -0,0 +1,214 @@
import type { Agent } from "@paperclipai/shared";
import type { LiveRunForIssue } from "../api/heartbeats";
import type {
IssueChatComment,
IssueChatLinkedRun,
IssueChatTranscriptEntry,
} from "../lib/issue-chat-messages";
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
export const LONG_THREAD_COMMENT_COUNT = 469;
export const LONG_THREAD_MARKDOWN_COMMENT_COUNT = 150;
export const LONG_THREAD_EVENT_COUNT = 12;
export const LONG_THREAD_LINKED_RUN_COUNT = 6;
const baseTime = new Date("2026-04-28T14:00:00.000Z").getTime();
function atMinute(offset: number) {
return new Date(baseTime + offset * 60_000);
}
function createAgent(id: string, name: string, icon: string, urlKey: string): Agent {
const now = new Date("2026-04-28T14:00:00.000Z");
return {
id,
companyId: "company-long-thread",
name,
urlKey,
role: "engineer",
title: null,
icon,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
lastHeartbeatAt: null,
metadata: null,
createdAt: now,
updatedAt: now,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
};
}
const primaryAgent = createAgent("agent-perf-codex", "CodexCoder", "code", "codexcoder");
const reviewerAgent = createAgent("agent-perf-reviewer", "ReviewBot", "sparkles", "reviewbot");
export const issueChatLongThreadAgentMap = new Map<string, Agent>([
[primaryAgent.id, primaryAgent],
[reviewerAgent.id, reviewerAgent],
]);
function markdownBody(index: number) {
return [
`## Baseline note ${index}`,
"",
`This assistant update captures a deterministic markdown-heavy row for long-thread rendering. It references [PAP-${2600 + index}](/PAP/issues/PAP-${2600 + index}) and includes enough structure to exercise markdown parsing.`,
"",
"- Parsed checklist item one with inline `code`",
"- Parsed checklist item two with **bold** and _italic_ text",
"- Parsed checklist item three with a link to [Paperclip](/PAP/dashboard)",
"",
"| Metric | Value |",
"| --- | ---: |",
`| Fixture row | ${index} |`,
`| Synthetic tokens | ${1200 + index} |`,
"",
"```ts",
`const fixtureRow${index} = { markdown: true, deterministic: true };`,
"```",
].join("\n");
}
function plainUserBody(index: number) {
return `Board checkpoint ${index}: keep the issue-detail page responsive while the thread is full of historical comments.`;
}
function plainAssistantBody(index: number) {
return `Processed checkpoint ${index}. The current direct-render path should keep this row mounted with the rest of the thread.`;
}
function createComment(index: number): IssueChatComment {
const isMarkdown = index < LONG_THREAD_MARKDOWN_COMMENT_COUNT;
const isAssistant = isMarkdown || index % 4 === 1 || index % 4 === 2;
const authorAgentId = isAssistant
? (index % 7 === 0 ? reviewerAgent.id : primaryAgent.id)
: null;
return {
id: `long-thread-comment-${String(index + 1).padStart(3, "0")}`,
companyId: "company-long-thread",
issueId: "issue-long-thread",
authorAgentId,
authorUserId: authorAgentId ? null : "user-board",
body: isMarkdown
? markdownBody(index + 1)
: authorAgentId
? plainAssistantBody(index + 1)
: plainUserBody(index + 1),
createdAt: atMinute(index),
updatedAt: atMinute(index),
};
}
export const issueChatLongThreadComments: IssueChatComment[] = Array.from(
{ length: LONG_THREAD_COMMENT_COUNT },
(_, index) => createComment(index),
);
export const issueChatLongThreadMarkdownCommentIds = new Set(
issueChatLongThreadComments
.slice(0, LONG_THREAD_MARKDOWN_COMMENT_COUNT)
.map((comment) => comment.id),
);
export const issueChatLongThreadEvents: IssueTimelineEvent[] = Array.from(
{ length: LONG_THREAD_EVENT_COUNT },
(_, index) => ({
id: `long-thread-event-${index + 1}`,
createdAt: atMinute(index * 36 + 18),
actorType: index % 3 === 0 ? "user" : "agent",
actorId: index % 3 === 0 ? "user-board" : primaryAgent.id,
statusChange: index % 2 === 0
? { from: index === 0 ? "todo" : "in_progress", to: "in_progress" }
: undefined,
assigneeChange: index % 2 === 1
? {
from: { agentId: null, userId: null },
to: { agentId: index % 4 === 1 ? primaryAgent.id : reviewerAgent.id, userId: null },
}
: undefined,
}),
);
export const issueChatLongThreadLinkedRuns: IssueChatLinkedRun[] = Array.from(
{ length: LONG_THREAD_LINKED_RUN_COUNT },
(_, index) => ({
runId: `long-thread-run-${index + 1}`,
status: index % 3 === 0 ? "failed" : index % 3 === 1 ? "timed_out" : "succeeded",
agentId: index % 2 === 0 ? primaryAgent.id : reviewerAgent.id,
agentName: index % 2 === 0 ? primaryAgent.name : reviewerAgent.name,
adapterType: "codex_local",
createdAt: atMinute(index * 72 + 12),
startedAt: atMinute(index * 72 + 12),
finishedAt: atMinute(index * 72 + 16),
hasStoredOutput: true,
}),
);
export const issueChatLongThreadLiveRuns: LiveRunForIssue[] = [];
export const issueChatLongThreadTranscriptsByRunId = new Map<string, readonly IssueChatTranscriptEntry[]>(
issueChatLongThreadLinkedRuns.map((run, index) => [
run.runId,
[
{
kind: "thinking",
ts: atMinute(index * 72 + 13).toISOString(),
text: `Inspecting long-thread segment ${index + 1}.`,
},
{
kind: "tool_call",
ts: atMinute(index * 72 + 14).toISOString(),
name: "read_file",
toolUseId: `long-thread-tool-${index + 1}`,
input: { path: "ui/src/components/IssueChatThread.tsx" },
},
{
kind: "tool_result",
ts: atMinute(index * 72 + 15).toISOString(),
toolUseId: `long-thread-tool-${index + 1}`,
content: "Confirmed the direct-render fixture keeps the full message subtree mounted.",
isError: run.status !== "succeeded",
},
{
kind: "assistant",
ts: atMinute(index * 72 + 16).toISOString(),
text: `Run ${index + 1} produced a compact transcript row for adjacent run context.`,
},
],
]),
);
export const issueChatLongThreadFixtureContext = {
issue: {
identifier: "PAP-PERF",
title: "Long-thread rendering baseline fixture",
status: "in_progress",
priority: "medium",
projectName: "Paperclip App",
},
documents: [
"Implementation Plan",
"Profiler Notes",
"Release Checklist",
"QA Readout",
],
subIssues: [
"Phase 1: Add long-thread perf fixture and baseline",
"Phase 2: Isolate issue-thread row rendering and Markdown work",
"Phase 3: Apply virtualization and guard scroll behavior",
"Phase 4: Verify production issue profile improvement",
],
sidebarStats: [
["Comments", String(LONG_THREAD_COMMENT_COUNT)],
["Markdown bodies", String(LONG_THREAD_MARKDOWN_COMMENT_COUNT)],
["Timeline events", String(LONG_THREAD_EVENT_COUNT)],
["Linked runs", String(LONG_THREAD_LINKED_RUN_COUNT)],
],
} as const;