Improve new issue dialog typing performance

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 07:58:05 -05:00
1 parent 48a7c43e23
commit 559a843a25
2 files changed
+244 -69

No files matched your search

+50
View File
@@ -372,6 +372,56 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
it("submits the latest locally typed title and description", async () => {
const { root } = renderDialog(container);
await flush();
const titleInput = container.querySelector('textarea[placeholder="Issue title"]') as HTMLTextAreaElement | null;
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement | null;
expect(titleInput).not.toBeNull();
expect(descriptionInput).not.toBeNull();
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(titleInput, "Typed issue");
titleInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(descriptionInput, "Typed description");
descriptionInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Create Issue"));
expect(submitButton).not.toBeUndefined();
expect(submitButton?.hasAttribute("disabled")).toBe(false);
await act(async () => {
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockIssuesApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
title: "Typed issue",
description: "Typed description",
}),
);
act(() => root.unmount());
});
it("submits the parent assignee when a sub-issue opens with inherited defaults", async () => {
dialogState.newIssueDefaults = {
parentId: "issue-1",
+194 -69
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}
>