feat(issues): add issue documents and inline editing

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-13 21:30:48 -05:00
parent 0f3e9937f6
commit 45998aa9a0
32 changed files with 9157 additions and 97 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
@@ -16,6 +16,7 @@ import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens } from "../lib/utils";
import { InlineEditor } from "../components/InlineEditor";
import { CommentThread } from "../components/CommentThread";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssueProperties } from "../components/IssueProperties";
import { LiveRunWidget } from "../components/LiveRunWidget";
import type { MentionOption } from "../components/MarkdownEditor";
@@ -60,6 +61,9 @@ const ACTION_LABELS: Record<string, string> = {
"issue.comment_added": "added a comment",
"issue.attachment_added": "added an attachment",
"issue.attachment_removed": "removed an attachment",
"issue.document_created": "created a document",
"issue.document_updated": "updated a document",
"issue.document_deleted": "deleted a document",
"issue.deleted": "deleted the issue",
"agent.created": "created an agent",
"agent.updated": "updated the agent",
@@ -97,6 +101,36 @@ function truncate(text: string, max: number): string {
return text.slice(0, max - 1) + "\u2026";
}
function isMarkdownFile(file: File) {
const name = file.name.toLowerCase();
return (
name.endsWith(".md") ||
name.endsWith(".markdown") ||
file.type === "text/markdown"
);
}
function fileBaseName(filename: string) {
return filename.replace(/\.[^.]+$/, "");
}
function slugifyDocumentKey(input: string) {
const slug = input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "document";
}
function titleizeFilename(input: string) {
return input
.split(/[-_ ]+/g)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function formatAction(action: string, details?: Record<string, unknown> | null): string {
if (action === "issue.updated" && details) {
const previous = (details._previous ?? {}) as Record<string, unknown>;
@@ -130,6 +164,14 @@ function formatAction(action: string, details?: Record<string, unknown> | null):
if (parts.length > 0) return parts.join(", ");
}
if (
(action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") &&
details
) {
const key = typeof details.key === "string" ? details.key : "document";
const title = typeof details.title === "string" && details.title ? ` (${details.title})` : "";
return `${ACTION_LABELS[action] ?? action} ${key}${title}`;
}
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
}
@@ -160,6 +202,7 @@ export function IssueDetail() {
cost: false,
});
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
@@ -384,6 +427,7 @@ export function IssueDetail() {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
if (selectedCompanyId) {
@@ -458,6 +502,30 @@ export function IssueDetail() {
},
});
const importMarkdownDocument = useMutation({
mutationFn: async (file: File) => {
const baseName = fileBaseName(file.name);
const key = slugifyDocumentKey(baseName);
const existing = (issue?.documentSummaries ?? []).find((doc) => doc.key === key) ?? null;
const body = await file.text();
const inferredTitle = titleizeFilename(baseName);
const nextTitle = existing?.title ?? inferredTitle ?? null;
return issuesApi.upsertDocument(issueId!, key, {
title: key === "plan" ? null : nextTitle,
format: "markdown",
body,
baseRevisionId: existing?.latestRevisionId ?? null,
});
},
onSuccess: () => {
setAttachmentError(null);
invalidateIssue();
},
onError: (err) => {
setAttachmentError(err instanceof Error ? err.message : "Document import failed");
},
});
const deleteAttachment = useMutation({
mutationFn: (attachmentId: string) => issuesApi.deleteAttachment(attachmentId),
onSuccess: () => {
@@ -509,14 +577,34 @@ export function IssueDetail() {
const ancestors = issue.ancestors ?? [];
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
const file = evt.target.files?.[0];
if (!file) return;
await uploadAttachment.mutateAsync(file);
const files = evt.target.files;
if (!files || files.length === 0) return;
for (const file of Array.from(files)) {
if (isMarkdownFile(file)) {
await importMarkdownDocument.mutateAsync(file);
} else {
await uploadAttachment.mutateAsync(file);
}
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleAttachmentDrop = async (evt: DragEvent<HTMLDivElement>) => {
evt.preventDefault();
setAttachmentDragActive(false);
const files = evt.dataTransfer.files;
if (!files || files.length === 0) return;
for (const file of Array.from(files)) {
if (isMarkdownFile(file)) {
await importMarkdownDocument.mutateAsync(file);
} else {
await uploadAttachment.mutateAsync(file);
}
}
};
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
return (
@@ -658,14 +746,14 @@ export function IssueDetail() {
<InlineEditor
value={issue.title}
onSave={(title) => updateIssue.mutate({ title })}
onSave={(title) => updateIssue.mutateAsync({ title })}
as="h2"
className="text-xl font-bold"
/>
<InlineEditor
value={issue.description ?? ""}
onSave={(description) => updateIssue.mutate({ description })}
onSave={(description) => updateIssue.mutateAsync({ description })}
as="p"
className="text-[15px] leading-7 text-foreground"
placeholder="Add a description..."
@@ -678,25 +766,62 @@ export function IssueDetail() {
/>
</div>
<div className="space-y-3">
<IssueDocumentsSection
issue={issue}
canDeleteDocuments={Boolean(session?.user?.id)}
mentions={mentionOptions}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
/>
<div
className={cn(
"space-y-3 rounded-lg transition-colors",
)}
onDragEnter={(evt) => {
evt.preventDefault();
setAttachmentDragActive(true);
}}
onDragOver={(evt) => {
evt.preventDefault();
setAttachmentDragActive(true);
}}
onDragLeave={(evt) => {
if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
setAttachmentDragActive(false);
}}
onDrop={(evt) => void handleAttachmentDrop(evt)}
>
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
<div className="flex items-center gap-2">
<div
className={cn(
"rounded-md border border-dashed border-border p-1 transition-colors",
attachmentDragActive && "border-primary bg-primary/5",
)}
>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
accept="image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"
className="hidden"
onChange={handleFilePicked}
multiple
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploadAttachment.isPending}
disabled={uploadAttachment.isPending || importMarkdownDocument.isPending}
className={cn(
"border-transparent bg-transparent shadow-none",
attachmentDragActive && "bg-transparent",
)}
>
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
{uploadAttachment.isPending ? "Uploading..." : "Upload image"}
{uploadAttachment.isPending || importMarkdownDocument.isPending ? "Uploading..." : "Upload attachment"}
</Button>
</div>
</div>