feat(issues): add issue documents and inline editing
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -41,6 +41,8 @@ export const api = {
|
||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
postForm: <T>(path: string, body: FormData) =>
|
||||
request<T>(path, { method: "POST", body }),
|
||||
put: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import type { Approval, Issue, IssueAttachment, IssueComment, IssueLabel } from "@paperclipai/shared";
|
||||
import type {
|
||||
Approval,
|
||||
DocumentRevision,
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueLabel,
|
||||
UpsertIssueDocument,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const issuesApi = {
|
||||
@@ -53,6 +62,14 @@ export const issuesApi = {
|
||||
...(interrupt === undefined ? {} : { interrupt }),
|
||||
},
|
||||
),
|
||||
listDocuments: (id: string) => api.get<IssueDocument[]>(`/issues/${id}/documents`),
|
||||
getDocument: (id: string, key: string) => api.get<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
||||
upsertDocument: (id: string, key: string, data: UpsertIssueDocument) =>
|
||||
api.put<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`, data),
|
||||
listDocumentRevisions: (id: string, key: string) =>
|
||||
api.get<DocumentRevision[]>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions`),
|
||||
deleteDocument: (id: string, key: string) =>
|
||||
api.delete<{ ok: true }>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
||||
listAttachments: (id: string) => api.get<IssueAttachment[]>(`/issues/${id}/attachments`),
|
||||
uploadAttachment: (
|
||||
companyId: string,
|
||||
|
||||
@@ -12,6 +12,9 @@ const ACTION_VERBS: Record<string, string> = {
|
||||
"issue.comment_added": "commented on",
|
||||
"issue.attachment_added": "attached file to",
|
||||
"issue.attachment_removed": "removed attachment from",
|
||||
"issue.document_created": "created document for",
|
||||
"issue.document_updated": "updated document on",
|
||||
"issue.document_deleted": "deleted document from",
|
||||
"issue.commented": "commented on",
|
||||
"issue.deleted": "deleted",
|
||||
"agent.created": "created",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
|
||||
interface InlineEditorProps {
|
||||
value: string;
|
||||
onSave: (value: string) => void;
|
||||
onSave: (value: string) => void | Promise<unknown>;
|
||||
as?: "h1" | "h2" | "p" | "span";
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
@@ -17,6 +16,8 @@ interface InlineEditorProps {
|
||||
|
||||
/** Shared padding so display and edit modes occupy the exact same box. */
|
||||
const pad = "px-1 -mx-1";
|
||||
const markdownPad = "px-1";
|
||||
const AUTOSAVE_DEBOUNCE_MS = 900;
|
||||
|
||||
export function InlineEditor({
|
||||
value,
|
||||
@@ -29,12 +30,30 @@ export function InlineEditor({
|
||||
mentions,
|
||||
}: InlineEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [multilineFocused, setMultilineFocused] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const markdownRef = useRef<MarkdownEditorRef>(null);
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const {
|
||||
state: autosaveState,
|
||||
markDirty,
|
||||
reset,
|
||||
runSave,
|
||||
} = useAutosaveIndicator();
|
||||
|
||||
useEffect(() => {
|
||||
if (multiline && multilineFocused) return;
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
}, [value, multiline, multilineFocused]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const autoSize = useCallback((el: HTMLTextAreaElement | null) => {
|
||||
if (!el) return;
|
||||
@@ -52,58 +71,140 @@ export function InlineEditor({
|
||||
}
|
||||
}, [editing, autoSize]);
|
||||
|
||||
function commit() {
|
||||
const trimmed = draft.trim();
|
||||
useEffect(() => {
|
||||
if (!editing || !multiline) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
markdownRef.current?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [editing, multiline]);
|
||||
|
||||
const commit = useCallback(async (nextValue = draft) => {
|
||||
const trimmed = nextValue.trim();
|
||||
if (trimmed && trimmed !== value) {
|
||||
onSave(trimmed);
|
||||
await Promise.resolve(onSave(trimmed));
|
||||
} else {
|
||||
setDraft(value);
|
||||
}
|
||||
setEditing(false);
|
||||
}
|
||||
if (!multiline) {
|
||||
setEditing(false);
|
||||
}
|
||||
}, [draft, multiline, onSave, value]);
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" && !multiline) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
void commit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
reset();
|
||||
setDraft(value);
|
||||
setEditing(false);
|
||||
if (multiline) {
|
||||
setMultilineFocused(false);
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
} else {
|
||||
setEditing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
if (multiline) {
|
||||
return (
|
||||
<div className={cn("space-y-2", pad)}>
|
||||
<MarkdownEditor
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder={placeholder}
|
||||
contentClassName={className}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
onSubmit={commit}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDraft(value);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={commit}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!multiline) return;
|
||||
if (!multilineFocused) return;
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed || trimmed === value) {
|
||||
if (autosaveState !== "saved") {
|
||||
reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
markDirty();
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
autosaveDebounceRef.current = setTimeout(() => {
|
||||
void runSave(() => commit(trimmed));
|
||||
}, AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, reset, runSave, value]);
|
||||
|
||||
if (multiline) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
markdownPad,
|
||||
"rounded transition-colors",
|
||||
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
|
||||
)}
|
||||
onFocusCapture={() => setMultilineFocused(true)}
|
||||
onBlurCapture={(event) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
setMultilineFocused(false);
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed || trimmed === value) {
|
||||
reset();
|
||||
void commit();
|
||||
return;
|
||||
}
|
||||
void runSave(() => commit());
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={markdownRef}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder={placeholder}
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={cn("paperclip-edit-in-place-content", className)}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
onSubmit={() => {
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed || trimmed === value) {
|
||||
reset();
|
||||
void commit();
|
||||
return;
|
||||
}
|
||||
void runSave(() => commit());
|
||||
}}
|
||||
/>
|
||||
<div className="flex min-h-4 items-center justify-end pr-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] transition-opacity duration-150",
|
||||
autosaveState === "error" ? "text-destructive" : "text-muted-foreground",
|
||||
autosaveState === "idle" ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
{autosaveState === "saving"
|
||||
? "Autosaving..."
|
||||
: autosaveState === "saved"
|
||||
? "Saved"
|
||||
: autosaveState === "error"
|
||||
? "Could not save"
|
||||
: "Idle"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
|
||||
return (
|
||||
<textarea
|
||||
@@ -114,7 +215,9 @@ export function InlineEditor({
|
||||
setDraft(e.target.value);
|
||||
autoSize(e.target);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onBlur={() => {
|
||||
void commit();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"w-full bg-transparent rounded outline-none resize-none overflow-hidden",
|
||||
@@ -135,15 +238,11 @@ export function InlineEditor({
|
||||
"cursor-pointer rounded hover:bg-accent/50 transition-colors overflow-hidden",
|
||||
pad,
|
||||
!value && "text-muted-foreground italic",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{value && multiline ? (
|
||||
<MarkdownBody>{value}</MarkdownBody>
|
||||
) : (
|
||||
value || placeholder
|
||||
)}
|
||||
{value || placeholder}
|
||||
</DisplayTag>
|
||||
);
|
||||
}
|
||||
|
||||
510
ui/src/components/IssueDocumentsSection.tsx
Normal file
510
ui/src/components/IssueDocumentsSection.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { relativeTime } from "../lib/utils";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||
|
||||
type DraftState = {
|
||||
key: string;
|
||||
title: string;
|
||||
body: string;
|
||||
baseRevisionId: string | null;
|
||||
isNew: boolean;
|
||||
};
|
||||
|
||||
const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900;
|
||||
|
||||
function renderBody(body: string, className?: string) {
|
||||
return <MarkdownBody className={className}>{body}</MarkdownBody>;
|
||||
}
|
||||
|
||||
function isPlanKey(key: string) {
|
||||
return key.trim().toLowerCase() === "plan";
|
||||
}
|
||||
|
||||
export function IssueDocumentsSection({
|
||||
issue,
|
||||
canDeleteDocuments,
|
||||
mentions,
|
||||
imageUploadHandler,
|
||||
}: {
|
||||
issue: Issue;
|
||||
canDeleteDocuments: boolean;
|
||||
mentions?: MentionOption[];
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<DraftState | null>(null);
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const {
|
||||
state: autosaveState,
|
||||
markDirty,
|
||||
reset,
|
||||
runSave,
|
||||
} = useAutosaveIndicator();
|
||||
|
||||
const { data: documents } = useQuery({
|
||||
queryKey: queryKeys.issues.documents(issue.id),
|
||||
queryFn: () => issuesApi.listDocuments(issue.id),
|
||||
});
|
||||
|
||||
const invalidateIssueDocuments = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) });
|
||||
};
|
||||
|
||||
const upsertDocument = useMutation({
|
||||
mutationFn: async (nextDraft: DraftState) =>
|
||||
issuesApi.upsertDocument(issue.id, nextDraft.key, {
|
||||
title: isPlanKey(nextDraft.key) ? null : nextDraft.title.trim() || null,
|
||||
format: "markdown",
|
||||
body: nextDraft.body,
|
||||
baseRevisionId: nextDraft.baseRevisionId,
|
||||
}),
|
||||
});
|
||||
|
||||
const deleteDocument = useMutation({
|
||||
mutationFn: (key: string) => issuesApi.deleteDocument(issue.id, key),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
setConfirmDeleteKey(null);
|
||||
invalidateIssueDocuments();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete document");
|
||||
},
|
||||
});
|
||||
|
||||
const sortedDocuments = useMemo(() => {
|
||||
return [...(documents ?? [])].sort((a, b) => {
|
||||
if (a.key === "plan" && b.key !== "plan") return -1;
|
||||
if (a.key !== "plan" && b.key === "plan") return 1;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
}, [documents]);
|
||||
|
||||
const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan");
|
||||
|
||||
const beginNewDocument = () => {
|
||||
reset();
|
||||
setDraft({
|
||||
key: "",
|
||||
title: "",
|
||||
body: "",
|
||||
baseRevisionId: null,
|
||||
isNew: true,
|
||||
});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const beginEdit = (key: string) => {
|
||||
const doc = sortedDocuments.find((entry) => entry.key === key);
|
||||
if (!doc) return;
|
||||
reset();
|
||||
setDraft({
|
||||
key: doc.key,
|
||||
title: doc.title ?? "",
|
||||
body: doc.body,
|
||||
baseRevisionId: doc.latestRevisionId,
|
||||
isNew: false,
|
||||
});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const cancelDraft = () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
reset();
|
||||
setDraft(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const commitDraft = useCallback(async (
|
||||
currentDraft: DraftState | null,
|
||||
options?: { clearAfterSave?: boolean; trackAutosave?: boolean },
|
||||
) => {
|
||||
if (!currentDraft || upsertDocument.isPending) return false;
|
||||
const normalizedKey = currentDraft.key.trim().toLowerCase();
|
||||
const normalizedBody = currentDraft.body.trim();
|
||||
const normalizedTitle = currentDraft.title.trim();
|
||||
|
||||
if (!normalizedKey || !normalizedBody) {
|
||||
if (currentDraft.isNew) {
|
||||
setError("Document key and body are required");
|
||||
} else if (!normalizedBody) {
|
||||
setError("Document body cannot be empty");
|
||||
}
|
||||
if (options?.trackAutosave) {
|
||||
reset();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = sortedDocuments.find((doc) => doc.key === normalizedKey);
|
||||
if (
|
||||
!currentDraft.isNew &&
|
||||
existing &&
|
||||
existing.body === currentDraft.body &&
|
||||
(existing.title ?? "") === currentDraft.title
|
||||
) {
|
||||
if (options?.clearAfterSave) {
|
||||
setDraft((value) => (value?.key === normalizedKey ? null : value));
|
||||
}
|
||||
if (options?.trackAutosave) {
|
||||
reset();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const saved = await upsertDocument.mutateAsync({
|
||||
...currentDraft,
|
||||
key: normalizedKey,
|
||||
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
||||
body: currentDraft.body,
|
||||
});
|
||||
setError(null);
|
||||
setDraft((value) => {
|
||||
if (!value || value.key !== normalizedKey) return value;
|
||||
if (options?.clearAfterSave) return null;
|
||||
return {
|
||||
key: saved.key,
|
||||
title: saved.title ?? "",
|
||||
body: saved.body,
|
||||
baseRevisionId: saved.latestRevisionId,
|
||||
isNew: false,
|
||||
};
|
||||
});
|
||||
invalidateIssueDocuments();
|
||||
};
|
||||
|
||||
try {
|
||||
if (options?.trackAutosave) {
|
||||
await runSave(save);
|
||||
} else {
|
||||
await save();
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save document");
|
||||
return false;
|
||||
}
|
||||
}, [invalidateIssueDocuments, reset, runSave, sortedDocuments, upsertDocument]);
|
||||
|
||||
const handleDraftBlur = async (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
await commitDraft(draft, { clearAfterSave: true, trackAutosave: true });
|
||||
};
|
||||
|
||||
const handleDraftKeyDown = async (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelDraft();
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
await commitDraft(draft, { clearAfterSave: false, trackAutosave: true });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft || draft.isNew) return;
|
||||
const existing = sortedDocuments.find((doc) => doc.key === draft.key);
|
||||
if (!existing) return;
|
||||
const hasChanges =
|
||||
existing.body !== draft.body ||
|
||||
(existing.title ?? "") !== draft.title;
|
||||
if (!hasChanges) {
|
||||
if (autosaveState !== "saved") {
|
||||
reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
markDirty();
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
autosaveDebounceRef.current = setTimeout(() => {
|
||||
void commitDraft(draft, { clearAfterSave: false, trackAutosave: true });
|
||||
}, DOCUMENT_AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [autosaveState, commitDraft, draft, markDirty, reset, sortedDocuments]);
|
||||
|
||||
const documentBodyShellClassName = "mt-3 overflow-hidden rounded-md border border-border bg-background";
|
||||
const documentBodyPaddingClassName = "px-3 py-3";
|
||||
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
||||
<Button variant="outline" size="sm" onClick={beginNewDocument}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New document
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
{draft?.isNew && (
|
||||
<div
|
||||
className="space-y-3 rounded-lg border border-border bg-accent/10 p-3"
|
||||
onBlurCapture={handleDraftBlur}
|
||||
onKeyDown={handleDraftKeyDown}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft.key}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => current ? { ...current, key: event.target.value.toLowerCase() } : current)
|
||||
}
|
||||
placeholder="Document key"
|
||||
/>
|
||||
{!isPlanKey(draft.key) && (
|
||||
<Input
|
||||
value={draft.title}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => current ? { ...current, title: event.target.value } : current)
|
||||
}
|
||||
placeholder="Optional title"
|
||||
/>
|
||||
)}
|
||||
<MarkdownEditor
|
||||
value={draft.body}
|
||||
onChange={(body) =>
|
||||
setDraft((current) => current ? { ...current, body } : current)
|
||||
}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName="min-h-[220px] px-3 py-3 text-[15px] leading-7"
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={cancelDraft}>
|
||||
<X className="mr-1.5 h-3.5 w-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })}
|
||||
disabled={upsertDocument.isPending}
|
||||
>
|
||||
{upsertDocument.isPending ? "Saving..." : "Create document"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedDocuments.length === 0 && !issue.legacyPlanDocument ? (
|
||||
<p className="text-xs text-muted-foreground">No documents yet.</p>
|
||||
) : null}
|
||||
|
||||
{!hasRealPlan && issue.legacyPlanDocument ? (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-amber-600" />
|
||||
<span className="rounded-full border border-amber-500/30 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-amber-700 dark:text-amber-300">
|
||||
PLAN
|
||||
</span>
|
||||
</div>
|
||||
<div className={documentBodyPaddingClassName}>
|
||||
{renderBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{sortedDocuments.map((doc) => {
|
||||
const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
|
||||
const showTitle = !isPlanKey(doc.key) && !!doc.title?.trim();
|
||||
|
||||
return (
|
||||
<div key={doc.id} className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{doc.key}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
rev {doc.latestRevisionNumber} • updated {relativeTime(doc.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>}
|
||||
</div>
|
||||
{canDeleteDocuments && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
title="Document actions"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setConfirmDeleteKey(doc.key)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete document
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onFocusCapture={() => {
|
||||
if (!activeDraft) {
|
||||
beginEdit(doc.key);
|
||||
}
|
||||
}}
|
||||
onBlurCapture={async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftBlur(event);
|
||||
}
|
||||
}}
|
||||
onKeyDown={async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftKeyDown(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{activeDraft && !isPlanKey(doc.key) && (
|
||||
<Input
|
||||
value={activeDraft.title}
|
||||
onChange={(event) => {
|
||||
markDirty();
|
||||
setDraft((current) => current ? { ...current, title: event.target.value } : current);
|
||||
}}
|
||||
placeholder="Optional title"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${
|
||||
activeDraft ? "" : "hover:bg-accent/10"
|
||||
}`}
|
||||
>
|
||||
<MarkdownEditor
|
||||
value={activeDraft?.body ?? doc.body}
|
||||
onChange={(body) => {
|
||||
markDirty();
|
||||
setDraft((current) => {
|
||||
if (current && current.key === doc.key && !current.isNew) {
|
||||
return { ...current, body };
|
||||
}
|
||||
return {
|
||||
key: doc.key,
|
||||
title: doc.title ?? "",
|
||||
body,
|
||||
baseRevisionId: doc.latestRevisionId,
|
||||
isNew: false,
|
||||
};
|
||||
});
|
||||
}}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={documentBodyContentClassName}
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-4 items-center justify-end px-1">
|
||||
<span
|
||||
className={`text-[11px] transition-opacity duration-150 ${
|
||||
autosaveState === "error" ? "text-destructive" : "text-muted-foreground"
|
||||
} ${activeDraft ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
{activeDraft
|
||||
? autosaveState === "saving"
|
||||
? "Autosaving..."
|
||||
: autosaveState === "saved"
|
||||
? "Saved"
|
||||
: autosaveState === "error"
|
||||
? "Could not save"
|
||||
: ""
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmDeleteKey === doc.key && (
|
||||
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border border-destructive/20 bg-destructive/5 px-4 py-3">
|
||||
<p className="text-sm text-destructive font-medium">
|
||||
Delete this document? This cannot be undone.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmDeleteKey(null)}
|
||||
disabled={deleteDocument.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteDocument.mutate(doc.key)}
|
||||
disabled={deleteDocument.isPending}
|
||||
>
|
||||
{deleteDocument.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
ui/src/hooks/useAutosaveIndicator.ts
Normal file
72
ui/src/hooks/useAutosaveIndicator.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type AutosaveState = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
const SAVING_DELAY_MS = 250;
|
||||
const SAVED_LINGER_MS = 1600;
|
||||
|
||||
export function useAutosaveIndicator() {
|
||||
const [state, setState] = useState<AutosaveState>("idle");
|
||||
const saveIdRef = useRef(0);
|
||||
const savingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clearSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (savingTimerRef.current) {
|
||||
clearTimeout(savingTimerRef.current);
|
||||
savingTimerRef.current = null;
|
||||
}
|
||||
if (clearSavedTimerRef.current) {
|
||||
clearTimeout(clearSavedTimerRef.current);
|
||||
clearSavedTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => clearTimers, [clearTimers]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
saveIdRef.current += 1;
|
||||
clearTimers();
|
||||
setState("idle");
|
||||
}, [clearTimers]);
|
||||
|
||||
const markDirty = useCallback(() => {
|
||||
clearTimers();
|
||||
setState("idle");
|
||||
}, [clearTimers]);
|
||||
|
||||
const runSave = useCallback(async (save: () => Promise<void>) => {
|
||||
const saveId = saveIdRef.current + 1;
|
||||
saveIdRef.current = saveId;
|
||||
clearTimers();
|
||||
savingTimerRef.current = setTimeout(() => {
|
||||
if (saveIdRef.current === saveId) {
|
||||
setState("saving");
|
||||
}
|
||||
}, SAVING_DELAY_MS);
|
||||
|
||||
try {
|
||||
await save();
|
||||
if (saveIdRef.current !== saveId) return;
|
||||
clearTimers();
|
||||
setState("saved");
|
||||
clearSavedTimerRef.current = setTimeout(() => {
|
||||
if (saveIdRef.current === saveId) {
|
||||
setState("idle");
|
||||
}
|
||||
}, SAVED_LINGER_MS);
|
||||
} catch (error) {
|
||||
if (saveIdRef.current !== saveId) throw error;
|
||||
clearTimers();
|
||||
setState("error");
|
||||
throw error;
|
||||
}
|
||||
}, [clearTimers]);
|
||||
|
||||
return {
|
||||
state,
|
||||
markDirty,
|
||||
reset,
|
||||
runSave,
|
||||
};
|
||||
}
|
||||
@@ -307,6 +307,11 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.paperclip-edit-in-place-content {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -317,11 +322,11 @@
|
||||
|
||||
.paperclip-mdxeditor-content p {
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content p + p {
|
||||
margin-top: 0.6rem;
|
||||
margin-top: 1.1em;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content a.paperclip-project-mention-chip {
|
||||
@@ -342,8 +347,8 @@
|
||||
|
||||
.paperclip-mdxeditor-content ul,
|
||||
.paperclip-mdxeditor-content ol {
|
||||
margin: 0.35rem 0;
|
||||
padding-left: 1.1rem;
|
||||
margin: 1.1em 0;
|
||||
padding-left: 1.6em;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content ul {
|
||||
@@ -356,32 +361,46 @@
|
||||
|
||||
.paperclip-mdxeditor-content li {
|
||||
display: list-item;
|
||||
margin: 0.15rem 0;
|
||||
line-height: 1.4;
|
||||
margin: 0.3em 0;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content li::marker {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content h1,
|
||||
.paperclip-mdxeditor-content h2,
|
||||
.paperclip-mdxeditor-content h3 {
|
||||
margin: 0.4rem 0 0.25rem;
|
||||
.paperclip-mdxeditor-content h1 {
|
||||
margin: 0 0 0.9em;
|
||||
font-size: 1.75em;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content h2 {
|
||||
margin: 0 0 0.85em;
|
||||
font-size: 1.35em;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content h3 {
|
||||
margin: 0 0 0.8em;
|
||||
font-size: 1.15em;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content img {
|
||||
max-height: 18rem;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content blockquote {
|
||||
margin: 0.45rem 0;
|
||||
padding-left: 0.7rem;
|
||||
border-left: 2px solid var(--border);
|
||||
margin: 1.2em 0;
|
||||
padding-left: 1em;
|
||||
border-left: 3px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.4;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content code {
|
||||
|
||||
@@ -27,6 +27,8 @@ export const queryKeys = {
|
||||
detail: (id: string) => ["issues", "detail", id] as const,
|
||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
||||
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,
|
||||
activity: (issueId: string) => ["issues", "activity", issueId] as const,
|
||||
runs: (issueId: string) => ["issues", "runs", issueId] as const,
|
||||
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user