mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
## 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:
parent
1991ec9d6f
commit
6b7f6ce4b8
48 changed files with 3388 additions and 260 deletions
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue