[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

@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react";
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent, type RefObject } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { pickTextColorForSolidBg } from "@/lib/color-contrast";
import { useDialog } from "../context/DialogContext";
@ -269,6 +269,111 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
return "shared_workspace";
}
const IssueTitleTextarea = memo(function IssueTitleTextarea({
value,
pending,
assigneeValue,
projectId,
descriptionEditorRef,
assigneeSelectorRef,
projectSelectorRef,
onChange,
}: {
value: string;
pending: boolean;
assigneeValue: string;
projectId: string;
descriptionEditorRef: RefObject<MarkdownEditorRef | null>;
assigneeSelectorRef: RefObject<HTMLButtonElement | null>;
projectSelectorRef: RefObject<HTMLButtonElement | null>;
onChange: (value: string) => void;
}) {
const [draftValue, setDraftValue] = useState(value);
useEffect(() => {
setDraftValue(value);
}, [value]);
return (
<textarea
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
placeholder="Issue title"
rows={1}
value={draftValue}
onChange={(e) => {
const nextValue = e.target.value;
setDraftValue(nextValue);
onChange(nextValue);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
readOnly={pending}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!e.metaKey &&
!e.ctrlKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault();
descriptionEditorRef.current?.focus();
}
if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault();
if (assigneeValue) {
if (projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
} else {
assigneeSelectorRef.current?.focus();
}
}
}}
autoFocus
/>
);
});
const IssueDescriptionEditor = memo(function IssueDescriptionEditor({
value,
expanded,
mentions,
descriptionEditorRef,
imageUploadHandler,
onChange,
}: {
value: string;
expanded: boolean;
mentions: MentionOption[];
descriptionEditorRef: RefObject<MarkdownEditorRef | null>;
imageUploadHandler: (file: File) => Promise<string>;
onChange: (value: string) => void;
}) {
const [draftValue, setDraftValue] = useState(value);
useEffect(() => {
setDraftValue(value);
}, [value]);
return (
<MarkdownEditor
ref={descriptionEditorRef}
value={draftValue}
onChange={(nextValue) => {
setDraftValue(nextValue);
onChange(nextValue);
}}
placeholder="Add description..."
bordered={false}
mentions={mentions}
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
imageUploadHandler={imageUploadHandler}
/>
);
});
function issueExecutionWorkspaceModeForExistingWorkspace(mode: string | null | undefined) {
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") {
return mode;
@ -286,6 +391,10 @@ export function NewIssueDialog() {
const { pushToast } = useToastActions();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const titleRef = useRef("");
const descriptionRef = useRef("");
const [titleHasText, setTitleHasText] = useState(false);
const [draftHasText, setDraftHasText] = useState(false);
const [status, setStatus] = useState("todo");
const [priority, setPriority] = useState("");
const [assigneeValue, setAssigneeValue] = useState("");
@ -463,6 +572,10 @@ export function NewIssueDialog() {
return assetsApi.uploadImage(effectiveCompanyId, file, "issues/drafts");
},
});
const uploadDescriptionImageHandler = useCallback(async (file: File) => {
const asset = await uploadDescriptionImage.mutateAsync(file);
return asset.contentPath;
}, [uploadDescriptionImage.mutateAsync]);
// Debounced draft saving
const scheduleSave = useCallback(
@ -475,12 +588,22 @@ export function NewIssueDialog() {
[],
);
// Save draft on meaningful changes
useEffect(() => {
const setIssueText = useCallback((nextTitle: string, nextDescription: string) => {
titleRef.current = nextTitle;
descriptionRef.current = nextDescription;
setTitle(nextTitle);
setDescription(nextDescription);
setTitleHasText(nextTitle.trim().length > 0);
setDraftHasText(nextTitle.trim().length > 0 || nextDescription.trim().length > 0);
}, []);
const queueDraftSave = useCallback((overrides: { title?: string; description?: string } = {}) => {
if (!newIssueOpen) return;
const nextTitle = overrides.title ?? titleRef.current;
const nextDescription = overrides.description ?? descriptionRef.current;
scheduleSave({
title,
description,
title: nextTitle,
description: nextDescription,
status,
priority,
assigneeValue,
@ -495,8 +618,43 @@ export function NewIssueDialog() {
selectedExecutionWorkspaceId,
});
}, [
title,
description,
newIssueOpen,
scheduleSave,
status,
priority,
assigneeValue,
reviewerValue,
approverValue,
projectId,
projectWorkspaceId,
assigneeModelOverride,
assigneeThinkingEffort,
assigneeChrome,
executionWorkspaceMode,
selectedExecutionWorkspaceId,
]);
const handleTitleChange = useCallback((nextTitle: string) => {
titleRef.current = nextTitle;
const nextTitleHasText = nextTitle.trim().length > 0;
const nextDraftHasText = nextTitleHasText || descriptionRef.current.trim().length > 0;
setTitleHasText((current) => current === nextTitleHasText ? current : nextTitleHasText);
setDraftHasText((current) => current === nextDraftHasText ? current : nextDraftHasText);
queueDraftSave({ title: nextTitle });
}, [queueDraftSave]);
const handleDescriptionChange = useCallback((nextDescription: string) => {
descriptionRef.current = nextDescription;
const nextDraftHasText = titleRef.current.trim().length > 0 || nextDescription.trim().length > 0;
setDraftHasText((current) => current === nextDraftHasText ? current : nextDraftHasText);
queueDraftSave({ description: nextDescription });
}, [queueDraftSave]);
// Save draft on meaningful changes
useEffect(() => {
if (!newIssueOpen) return;
queueDraftSave();
}, [
status,
priority,
assigneeValue,
@ -510,7 +668,7 @@ export function NewIssueDialog() {
executionWorkspaceMode,
selectedExecutionWorkspaceId,
newIssueOpen,
scheduleSave,
queueDraftSave,
]);
// Restore draft or apply defaults when dialog opens
@ -528,8 +686,7 @@ export function NewIssueDialog() {
const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId
? "reuse_existing"
: (newIssueDefaults.executionWorkspaceMode ?? defaultExecutionWorkspaceModeForProject(defaultProject));
setTitle(newIssueDefaults.title ?? "");
setDescription(newIssueDefaults.description ?? "");
setIssueText(newIssueDefaults.title ?? "", newIssueDefaults.description ?? "");
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(defaultProjectId);
@ -542,8 +699,7 @@ export function NewIssueDialog() {
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
} else if (newIssueDefaults.title) {
setTitle(newIssueDefaults.title);
setDescription(newIssueDefaults.description ?? "");
setIssueText(newIssueDefaults.title, newIssueDefaults.description ?? "");
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
const defaultProjectId = newIssueDefaults.projectId ?? "";
@ -564,8 +720,7 @@ export function NewIssueDialog() {
} else if (draft && draft.title.trim()) {
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
setTitle(draft.title);
setDescription(draft.description);
setIssueText(draft.title, draft.description);
setStatus(draft.status || "todo");
setPriority(draft.priority);
setAssigneeValue(
@ -591,6 +746,7 @@ export function NewIssueDialog() {
} else {
const defaultProjectId = newIssueDefaults.projectId ?? "";
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
setIssueText("", "");
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(defaultProjectId);
@ -607,7 +763,7 @@ export function NewIssueDialog() {
setSelectedExecutionWorkspaceId("");
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
}
}, [newIssueOpen, newIssueDefaults, orderedProjects]);
}, [newIssueOpen, newIssueDefaults, orderedProjects, setIssueText]);
useEffect(() => {
if (!supportsAssigneeOverrides) {
@ -637,8 +793,7 @@ export function NewIssueDialog() {
}, []);
function reset() {
setTitle("");
setDescription("");
setIssueText("", "");
setStatus("todo");
setPriority("");
setAssigneeValue("");
@ -687,7 +842,9 @@ export function NewIssueDialog() {
}
function handleSubmit() {
if (!effectiveCompanyId || !title.trim() || createIssue.isPending) return;
const currentTitle = titleRef.current.trim();
const currentDescription = descriptionRef.current.trim();
if (!effectiveCompanyId || !currentTitle || createIssue.isPending) return;
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
adapterType: assigneeAdapterType,
modelOverride: assigneeModelOverride,
@ -716,8 +873,8 @@ export function NewIssueDialog() {
createIssue.mutate({
companyId: effectiveCompanyId,
stagedFiles,
title: title.trim(),
description: description.trim() || undefined,
title: currentTitle,
description: currentDescription || undefined,
status,
priority: priority || "medium",
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
@ -806,7 +963,7 @@ export function NewIssueDialog() {
setStagedFiles((current) => current.filter((file) => file.id !== id));
}
const hasDraft = title.trim().length > 0 || description.trim().length > 0 || stagedFiles.length > 0;
const hasDraft = draftHasText || stagedFiles.length > 0;
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
const currentPriority = priorities.find((p) => p.value === priority);
const currentAssignee = selectedAssigneeAgentId
@ -884,7 +1041,7 @@ export function NewIssueDialog() {
})),
[orderedProjects],
);
const savedDraft = loadDraft();
const savedDraft = useMemo(() => newIssueOpen ? loadDraft() : null, [newIssueOpen]);
const hasSavedDraft = Boolean(savedDraft?.title.trim() || savedDraft?.description.trim());
const canDiscardDraft = hasDraft || hasSavedDraft;
const createIssueErrorMessage =
@ -1056,42 +1213,15 @@ export function NewIssueDialog() {
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{/* Title */}
<div className="px-4 pt-4 pb-2">
<textarea
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
placeholder="Issue title"
rows={1}
value={title}
onChange={(e) => {
setTitle(e.target.value);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
readOnly={createIssue.isPending}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!e.metaKey &&
!e.ctrlKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault();
descriptionEditorRef.current?.focus();
}
if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault();
if (assigneeValue) {
// Assignee already set — skip to project or description
if (projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
} else {
assigneeSelectorRef.current?.focus();
}
}
}}
autoFocus
<IssueTitleTextarea
value={title}
pending={createIssue.isPending}
assigneeValue={assigneeValue}
projectId={projectId}
descriptionEditorRef={descriptionEditorRef}
assigneeSelectorRef={assigneeSelectorRef}
projectSelectorRef={projectSelectorRef}
onChange={handleTitleChange}
/>
</div>
@ -1466,18 +1596,13 @@ export function NewIssueDialog() {
isFileDragOver && "bg-accent/20",
)}
>
<MarkdownEditor
ref={descriptionEditorRef}
<IssueDescriptionEditor
value={description}
onChange={setDescription}
placeholder="Add description..."
bordered={false}
expanded={expanded}
mentions={mentionOptions}
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
imageUploadHandler={async (file) => {
const asset = await uploadDescriptionImage.mutateAsync(file);
return asset.contentPath;
}}
descriptionEditorRef={descriptionEditorRef}
imageUploadHandler={uploadDescriptionImageHandler}
onChange={handleDescriptionChange}
/>
</div>
{stagedFiles.length > 0 ? (
@ -1682,7 +1807,7 @@ export function NewIssueDialog() {
<Button
size="sm"
className="min-w-[8.5rem] disabled:opacity-100"
disabled={!title.trim() || createIssue.isPending}
disabled={!titleHasText || createIssue.isPending}
onClick={handleSubmit}
aria-busy={createIssue.isPending}
>