Merge public-gh/master into feature/plugin-runtime-instance-cleanup
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -444,23 +444,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
/>
|
||||
</Field>
|
||||
{isLocal && (
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
value={eff(
|
||||
"adapterConfig",
|
||||
"promptTemplate",
|
||||
String(config.promptTemplate ?? ""),
|
||||
)}
|
||||
onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = `agents/${props.agent.id}/prompt-template`;
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<>
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
value={eff(
|
||||
"adapterConfig",
|
||||
"promptTemplate",
|
||||
String(config.promptTemplate ?? ""),
|
||||
)}
|
||||
onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = `agents/${props.agent.id}/prompt-template`;
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,19 +581,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
|
||||
{/* Prompt template (create mode only — edit mode shows this in Identity) */}
|
||||
{isLocal && isCreate && (
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
value={val!.promptTemplate}
|
||||
onChange={(v) => set!({ promptTemplate: v })}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = "agents/drafts/prompt-template";
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<>
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
value={val!.promptTemplate}
|
||||
onChange={(v) => set!({ promptTemplate: v })}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = "agents/drafts/prompt-template";
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
Prompt template is replayed on every heartbeat. Prefer small task framing and variables like <code>{"{{ context.* }}"}</code> or <code>{"{{ run.* }}"}</code>; avoid repeating stable instructions here.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Adapter-specific fields */}
|
||||
@@ -704,6 +714,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-2 text-xs text-sky-100">
|
||||
Bootstrap prompt is only sent for fresh sessions. Put stable setup, habits, and longer reusable guidance here. Frequent changes reduce the value of session reuse because new sessions must replay it.
|
||||
</div>
|
||||
{adapterType === "claude_local" && (
|
||||
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
889
ui/src/components/IssueDocumentsSection.tsx
Normal file
889
ui/src/components/IssueDocumentsSection.tsx
Normal file
@@ -0,0 +1,889 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Issue, IssueDocument } from "@paperclipai/shared";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { ApiError } from "../api/client";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, 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 { Check, ChevronDown, ChevronRight, Copy, Download, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||
|
||||
type DraftState = {
|
||||
key: string;
|
||||
title: string;
|
||||
body: string;
|
||||
baseRevisionId: string | null;
|
||||
isNew: boolean;
|
||||
};
|
||||
|
||||
type DocumentConflictState = {
|
||||
key: string;
|
||||
serverDocument: IssueDocument;
|
||||
localDraft: DraftState;
|
||||
showRemote: boolean;
|
||||
};
|
||||
|
||||
const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900;
|
||||
const DOCUMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
||||
const getFoldedDocumentsStorageKey = (issueId: string) => `paperclip:issue-document-folds:${issueId}`;
|
||||
|
||||
function loadFoldedDocumentKeys(issueId: string) {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(getFoldedDocumentsStorageKey(issueId));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys));
|
||||
}
|
||||
|
||||
function renderBody(body: string, className?: string) {
|
||||
return <MarkdownBody className={className}>{body}</MarkdownBody>;
|
||||
}
|
||||
|
||||
function isPlanKey(key: string) {
|
||||
return key.trim().toLowerCase() === "plan";
|
||||
}
|
||||
|
||||
function titlesMatchKey(title: string | null | undefined, key: string) {
|
||||
return (title ?? "").trim().toLowerCase() === key.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isDocumentConflictError(error: unknown) {
|
||||
return error instanceof ApiError && error.status === 409;
|
||||
}
|
||||
|
||||
function downloadDocumentFile(key: string, body: string) {
|
||||
const blob = new Blob([body], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${key}.md`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function IssueDocumentsSection({
|
||||
issue,
|
||||
canDeleteDocuments,
|
||||
mentions,
|
||||
imageUploadHandler,
|
||||
extraActions,
|
||||
}: {
|
||||
issue: Issue;
|
||||
canDeleteDocuments: boolean;
|
||||
mentions?: MentionOption[];
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
extraActions?: ReactNode;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<DraftState | null>(null);
|
||||
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
|
||||
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(issue.id));
|
||||
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
|
||||
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
|
||||
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasScrolledToHashRef = useRef(false);
|
||||
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 isEmpty = sortedDocuments.length === 0 && !issue.legacyPlanDocument;
|
||||
const newDocumentKeyError =
|
||||
draft?.isNew && draft.key.trim().length > 0 && !DOCUMENT_KEY_PATTERN.test(draft.key.trim())
|
||||
? "Use lowercase letters, numbers, -, or _, and start with a letter or number."
|
||||
: null;
|
||||
|
||||
const resetAutosaveState = useCallback(() => {
|
||||
setAutosaveDocumentKey(null);
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const markDocumentDirty = useCallback((key: string) => {
|
||||
setAutosaveDocumentKey(key);
|
||||
markDirty();
|
||||
}, [markDirty]);
|
||||
|
||||
const beginNewDocument = () => {
|
||||
resetAutosaveState();
|
||||
setDocumentConflict(null);
|
||||
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;
|
||||
const conflictedDraft = documentConflict?.key === key ? documentConflict.localDraft : null;
|
||||
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key));
|
||||
resetAutosaveState();
|
||||
setDocumentConflict((current) => current?.key === key ? current : null);
|
||||
setDraft({
|
||||
key: conflictedDraft?.key ?? doc.key,
|
||||
title: conflictedDraft?.title ?? doc.title ?? "",
|
||||
body: conflictedDraft?.body ?? doc.body,
|
||||
baseRevisionId: conflictedDraft?.baseRevisionId ?? doc.latestRevisionId,
|
||||
isNew: false,
|
||||
});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const cancelDraft = () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
resetAutosaveState();
|
||||
setDocumentConflict(null);
|
||||
setDraft(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const commitDraft = useCallback(async (
|
||||
currentDraft: DraftState | null,
|
||||
options?: { clearAfterSave?: boolean; trackAutosave?: boolean; overrideConflict?: boolean },
|
||||
) => {
|
||||
if (!currentDraft || upsertDocument.isPending) return false;
|
||||
const normalizedKey = currentDraft.key.trim().toLowerCase();
|
||||
const normalizedBody = currentDraft.body.trim();
|
||||
const normalizedTitle = currentDraft.title.trim();
|
||||
const activeConflict = documentConflict?.key === normalizedKey ? documentConflict : null;
|
||||
|
||||
if (activeConflict && !options?.overrideConflict) {
|
||||
if (options?.trackAutosave) {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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) {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DOCUMENT_KEY_PATTERN.test(normalizedKey)) {
|
||||
setError("Document key must start with a letter or number and use only lowercase letters, numbers, -, or _.");
|
||||
if (options?.trackAutosave) {
|
||||
resetAutosaveState();
|
||||
}
|
||||
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) {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const saved = await upsertDocument.mutateAsync({
|
||||
...currentDraft,
|
||||
key: normalizedKey,
|
||||
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
||||
body: currentDraft.body,
|
||||
baseRevisionId: options?.overrideConflict
|
||||
? activeConflict?.serverDocument.latestRevisionId ?? currentDraft.baseRevisionId
|
||||
: currentDraft.baseRevisionId,
|
||||
});
|
||||
setError(null);
|
||||
setDocumentConflict((current) => current?.key === normalizedKey ? null : current);
|
||||
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) {
|
||||
setAutosaveDocumentKey(normalizedKey);
|
||||
await runSave(save);
|
||||
} else {
|
||||
await save();
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isDocumentConflictError(err)) {
|
||||
try {
|
||||
const latestDocument = await issuesApi.getDocument(issue.id, normalizedKey);
|
||||
setDocumentConflict({
|
||||
key: normalizedKey,
|
||||
serverDocument: latestDocument,
|
||||
localDraft: {
|
||||
key: normalizedKey,
|
||||
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
||||
body: currentDraft.body,
|
||||
baseRevisionId: currentDraft.baseRevisionId,
|
||||
isNew: false,
|
||||
},
|
||||
showRemote: true,
|
||||
});
|
||||
setFoldedDocumentKeys((current) => current.filter((key) => key !== normalizedKey));
|
||||
setError(null);
|
||||
resetAutosaveState();
|
||||
return false;
|
||||
} catch {
|
||||
setError("Document changed remotely and the latest version could not be loaded");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to save document");
|
||||
return false;
|
||||
}
|
||||
}, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, upsertDocument]);
|
||||
|
||||
const reloadDocumentFromServer = useCallback((key: string) => {
|
||||
if (documentConflict?.key !== key) return;
|
||||
const serverDocument = documentConflict.serverDocument;
|
||||
setDraft({
|
||||
key: serverDocument.key,
|
||||
title: serverDocument.title ?? "",
|
||||
body: serverDocument.body,
|
||||
baseRevisionId: serverDocument.latestRevisionId,
|
||||
isNew: false,
|
||||
});
|
||||
setDocumentConflict(null);
|
||||
resetAutosaveState();
|
||||
setError(null);
|
||||
}, [documentConflict, resetAutosaveState]);
|
||||
|
||||
const overwriteDocumentFromDraft = useCallback(async (key: string) => {
|
||||
if (documentConflict?.key !== key) return;
|
||||
const sourceDraft =
|
||||
draft && draft.key === key && !draft.isNew
|
||||
? draft
|
||||
: documentConflict.localDraft;
|
||||
await commitDraft(
|
||||
{
|
||||
...sourceDraft,
|
||||
baseRevisionId: documentConflict.serverDocument.latestRevisionId,
|
||||
},
|
||||
{
|
||||
clearAfterSave: false,
|
||||
trackAutosave: true,
|
||||
overrideConflict: true,
|
||||
},
|
||||
);
|
||||
}, [commitDraft, documentConflict, draft]);
|
||||
|
||||
const keepConflictedDraft = useCallback((key: string) => {
|
||||
if (documentConflict?.key !== key) return;
|
||||
setDraft(documentConflict.localDraft);
|
||||
setDocumentConflict((current) =>
|
||||
current?.key === key
|
||||
? { ...current, showRemote: false }
|
||||
: current,
|
||||
);
|
||||
setError(null);
|
||||
}, [documentConflict]);
|
||||
|
||||
const copyDocumentBody = useCallback(async (key: string, body: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(body);
|
||||
setCopiedDocumentKey(key);
|
||||
if (copiedDocumentTimerRef.current) {
|
||||
clearTimeout(copiedDocumentTimerRef.current);
|
||||
}
|
||||
copiedDocumentTimerRef.current = setTimeout(() => {
|
||||
setCopiedDocumentKey((current) => current === key ? null : current);
|
||||
}, 1400);
|
||||
} catch {
|
||||
setError("Could not copy document");
|
||||
}
|
||||
}, []);
|
||||
|
||||
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(() => {
|
||||
setFoldedDocumentKeys(loadFoldedDocumentKeys(issue.id));
|
||||
}, [issue.id]);
|
||||
|
||||
useEffect(() => {
|
||||
hasScrolledToHashRef.current = false;
|
||||
}, [issue.id, location.hash]);
|
||||
|
||||
useEffect(() => {
|
||||
const validKeys = new Set(sortedDocuments.map((doc) => doc.key));
|
||||
setFoldedDocumentKeys((current) => {
|
||||
const next = current.filter((key) => validKeys.has(key));
|
||||
if (next.length !== current.length) {
|
||||
saveFoldedDocumentKeys(issue.id, next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [issue.id, sortedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
saveFoldedDocumentKeys(issue.id, foldedDocumentKeys);
|
||||
}, [foldedDocumentKeys, issue.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentConflict) return;
|
||||
const latest = sortedDocuments.find((doc) => doc.key === documentConflict.key);
|
||||
if (!latest || latest.latestRevisionId === documentConflict.serverDocument.latestRevisionId) return;
|
||||
setDocumentConflict((current) =>
|
||||
current?.key === latest.key
|
||||
? { ...current, serverDocument: latest }
|
||||
: current,
|
||||
);
|
||||
}, [documentConflict, sortedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
if (!hash.startsWith("#document-")) return;
|
||||
const documentKey = decodeURIComponent(hash.slice("#document-".length));
|
||||
const targetExists = sortedDocuments.some((doc) => doc.key === documentKey)
|
||||
|| (documentKey === "plan" && Boolean(issue.legacyPlanDocument));
|
||||
if (!targetExists || hasScrolledToHashRef.current) return;
|
||||
setFoldedDocumentKeys((current) => current.filter((key) => key !== documentKey));
|
||||
const element = document.getElementById(`document-${documentKey}`);
|
||||
if (!element) return;
|
||||
hasScrolledToHashRef.current = true;
|
||||
setHighlightDocumentKey(documentKey);
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const timer = setTimeout(() => setHighlightDocumentKey((current) => current === documentKey ? null : current), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [issue.legacyPlanDocument, location.hash, sortedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
if (copiedDocumentTimerRef.current) {
|
||||
clearTimeout(copiedDocumentTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft || draft.isNew) return;
|
||||
if (documentConflict?.key === draft.key) 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") {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
markDocumentDirty(draft.key);
|
||||
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, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]);
|
||||
|
||||
const documentBodyShellClassName = "mt-3 overflow-hidden rounded-md";
|
||||
const documentBodyPaddingClassName = "";
|
||||
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7";
|
||||
const toggleFoldedDocument = (key: string) => {
|
||||
setFoldedDocumentKeys((current) =>
|
||||
current.includes(key)
|
||||
? current.filter((entry) => entry !== key)
|
||||
: [...current, key],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{isEmpty && !draft?.isNew ? (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{extraActions}
|
||||
<Button variant="outline" size="sm" onClick={beginNewDocument}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New document
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{extraActions}
|
||||
<Button variant="outline" size="sm" onClick={beginNewDocument}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New document
|
||||
</Button>
|
||||
</div>
|
||||
</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"
|
||||
/>
|
||||
{newDocumentKeyError && (
|
||||
<p className="text-xs text-destructive">{newDocumentKeyError}</p>
|
||||
)}
|
||||
{!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] 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>
|
||||
)}
|
||||
|
||||
{!hasRealPlan && issue.legacyPlanDocument ? (
|
||||
<div
|
||||
id="document-plan"
|
||||
className={cn(
|
||||
"rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 transition-colors duration-1000",
|
||||
highlightDocumentKey === "plan" && "border-primary/50 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<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 activeConflict = documentConflict?.key === doc.key ? documentConflict : null;
|
||||
const isFolded = foldedDocumentKeys.includes(doc.key);
|
||||
const showTitle = !isPlanKey(doc.key) && !!doc.title?.trim() && !titlesMatchKey(doc.title, doc.key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
id={`document-${doc.key}`}
|
||||
className={cn(
|
||||
"rounded-lg border border-border p-3 transition-colors duration-1000",
|
||||
highlightDocumentKey === doc.key && "border-primary/50 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
onClick={() => toggleFoldedDocument(doc.key)}
|
||||
aria-label={isFolded ? `Expand ${doc.key} document` : `Collapse ${doc.key} document`}
|
||||
aria-expanded={!isFolded}
|
||||
>
|
||||
{isFolded ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<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>
|
||||
<a
|
||||
href={`#document-${encodeURIComponent(doc.key)}`}
|
||||
className="text-[11px] text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
rev {doc.latestRevisionNumber} • updated {relativeTime(doc.updatedAt)}
|
||||
</a>
|
||||
</div>
|
||||
{showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn(
|
||||
"text-muted-foreground transition-colors",
|
||||
copiedDocumentKey === doc.key && "text-foreground",
|
||||
)}
|
||||
title={copiedDocumentKey === doc.key ? "Copied" : "Copy document"}
|
||||
onClick={() => void copyDocumentBody(doc.key, activeDraft?.body ?? doc.body)}
|
||||
>
|
||||
{copiedDocumentKey === doc.key ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<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">
|
||||
<DropdownMenuItem
|
||||
onClick={() => downloadDocumentFile(doc.key, activeDraft?.body ?? doc.body)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download document
|
||||
</DropdownMenuItem>
|
||||
{canDeleteDocuments ? <DropdownMenuSeparator /> : null}
|
||||
{canDeleteDocuments ? (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setConfirmDeleteKey(doc.key)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete document
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isFolded ? (
|
||||
<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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{activeConflict && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-amber-200">Out of date</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This document changed while you were editing. Your local draft is preserved and autosave is paused.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setDocumentConflict((current) =>
|
||||
current?.key === doc.key
|
||||
? { ...current, showRemote: !current.showRemote }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
>
|
||||
{activeConflict.showRemote ? "Hide remote" : "Review remote"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => keepConflictedDraft(doc.key)}
|
||||
>
|
||||
Keep my draft
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => reloadDocumentFromServer(doc.key)}
|
||||
>
|
||||
Reload remote
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void overwriteDocumentFromDraft(doc.key)}
|
||||
disabled={upsertDocument.isPending}
|
||||
>
|
||||
{upsertDocument.isPending ? "Saving..." : "Overwrite remote"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{activeConflict.showRemote && (
|
||||
<div className="mt-3 rounded-md border border-border/70 bg-background/60 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>Remote revision {activeConflict.serverDocument.latestRevisionNumber}</span>
|
||||
<span>•</span>
|
||||
<span>updated {relativeTime(activeConflict.serverDocument.updatedAt)}</span>
|
||||
</div>
|
||||
{!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (
|
||||
<p className="mb-2 text-sm font-medium">{activeConflict.serverDocument.title}</p>
|
||||
) : null}
|
||||
{renderBody(activeConflict.serverDocument.body, "text-[14px] leading-7")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeDraft && !isPlanKey(doc.key) && (
|
||||
<Input
|
||||
value={activeDraft.title}
|
||||
onChange={(event) => {
|
||||
markDocumentDirty(doc.key);
|
||||
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) => {
|
||||
markDocumentDirty(doc.key);
|
||||
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 ${
|
||||
activeConflict
|
||||
? "text-amber-300"
|
||||
: autosaveState === "error"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
} ${activeDraft ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
{activeDraft
|
||||
? activeConflict
|
||||
? "Out of date"
|
||||
: autosaveDocumentKey === doc.key
|
||||
? autosaveState === "saving"
|
||||
? "Autosaving..."
|
||||
: autosaveState === "saved"
|
||||
? "Saved"
|
||||
: autosaveState === "error"
|
||||
? "Could not save"
|
||||
: ""
|
||||
: ""
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -10,6 +10,7 @@ import { assetsApi } from "../api/assets";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import {
|
||||
assigneeValueFromSelection,
|
||||
currentUserAssigneeOption,
|
||||
@@ -39,7 +40,9 @@ import {
|
||||
Tag,
|
||||
Calendar,
|
||||
Paperclip,
|
||||
FileText,
|
||||
Loader2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { extractProviderIdWithFallback } from "../lib/model-utils";
|
||||
@@ -77,7 +80,16 @@ interface IssueDraft {
|
||||
useIsolatedExecutionWorkspace: boolean;
|
||||
}
|
||||
|
||||
type StagedIssueFile = {
|
||||
id: string;
|
||||
file: File;
|
||||
kind: "document" | "attachment";
|
||||
documentKey?: string;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
||||
const STAGED_FILE_ACCEPT = "image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown";
|
||||
|
||||
const ISSUE_THINKING_EFFORT_OPTIONS = {
|
||||
claude_local: [
|
||||
@@ -156,6 +168,59 @@ function clearDraft() {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
}
|
||||
|
||||
function isTextDocumentFile(file: File) {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
name.endsWith(".md") ||
|
||||
name.endsWith(".markdown") ||
|
||||
name.endsWith(".txt") ||
|
||||
file.type === "text/markdown" ||
|
||||
file.type === "text/plain"
|
||||
);
|
||||
}
|
||||
|
||||
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 createUniqueDocumentKey(baseKey: string, stagedFiles: StagedIssueFile[]) {
|
||||
const existingKeys = new Set(
|
||||
stagedFiles
|
||||
.filter((file) => file.kind === "document")
|
||||
.map((file) => file.documentKey)
|
||||
.filter((key): key is string => Boolean(key)),
|
||||
);
|
||||
if (!existingKeys.has(baseKey)) return baseKey;
|
||||
let suffix = 2;
|
||||
while (existingKeys.has(`${baseKey}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
return `${baseKey}-${suffix}`;
|
||||
}
|
||||
|
||||
function formatFileSize(file: File) {
|
||||
if (file.size < 1024) return `${file.size} B`;
|
||||
if (file.size < 1024 * 1024) return `${(file.size / 1024).toFixed(1)} KB`;
|
||||
return `${(file.size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const statuses = [
|
||||
{ value: "backlog", label: "Backlog", color: issueStatusText.backlog ?? issueStatusTextDefault },
|
||||
{ value: "todo", label: "Todo", color: issueStatusText.todo ?? issueStatusTextDefault },
|
||||
@@ -175,6 +240,7 @@ export function NewIssueDialog() {
|
||||
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
||||
const { companies, selectedCompanyId, selectedCompany } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("todo");
|
||||
@@ -188,6 +254,8 @@ export function NewIssueDialog() {
|
||||
const [useIsolatedExecutionWorkspace, setUseIsolatedExecutionWorkspace] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
|
||||
const [stagedFiles, setStagedFiles] = useState<StagedIssueFile[]>([]);
|
||||
const [isFileDragOver, setIsFileDragOver] = useState(false);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const executionWorkspaceDefaultProjectId = useRef<string | null>(null);
|
||||
|
||||
@@ -200,7 +268,7 @@ export function NewIssueDialog() {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [companyOpen, setCompanyOpen] = useState(false);
|
||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const stageFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
@@ -268,11 +336,49 @@ export function NewIssueDialog() {
|
||||
});
|
||||
|
||||
const createIssue = useMutation({
|
||||
mutationFn: ({ companyId, ...data }: { companyId: string } & Record<string, unknown>) =>
|
||||
issuesApi.create(companyId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) });
|
||||
mutationFn: async ({
|
||||
companyId,
|
||||
stagedFiles: pendingStagedFiles,
|
||||
...data
|
||||
}: { companyId: string; stagedFiles: StagedIssueFile[] } & Record<string, unknown>) => {
|
||||
const issue = await issuesApi.create(companyId, data);
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const stagedFile of pendingStagedFiles) {
|
||||
try {
|
||||
if (stagedFile.kind === "document") {
|
||||
const body = await stagedFile.file.text();
|
||||
await issuesApi.upsertDocument(issue.id, stagedFile.documentKey ?? "document", {
|
||||
title: stagedFile.documentKey === "plan" ? null : stagedFile.title ?? null,
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: null,
|
||||
});
|
||||
} else {
|
||||
await issuesApi.uploadAttachment(companyId, issue.id, stagedFile.file);
|
||||
}
|
||||
} catch {
|
||||
failures.push(stagedFile.file.name);
|
||||
}
|
||||
}
|
||||
|
||||
return { issue, companyId, failures };
|
||||
},
|
||||
onSuccess: ({ issue, companyId, failures }) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
if (failures.length > 0) {
|
||||
const prefix = (companies.find((company) => company.id === companyId)?.issuePrefix ?? "").trim();
|
||||
const issueRef = issue.identifier ?? issue.id;
|
||||
pushToast({
|
||||
title: `Created ${issueRef} with upload warnings`,
|
||||
body: `${failures.length} staged ${failures.length === 1 ? "file" : "files"} could not be added.`,
|
||||
tone: "warn",
|
||||
action: prefix
|
||||
? { label: `Open ${issueRef}`, href: `/${prefix}/issues/${issueRef}` }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
clearDraft();
|
||||
reset();
|
||||
closeNewIssue();
|
||||
@@ -413,6 +519,8 @@ export function NewIssueDialog() {
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
setExpanded(false);
|
||||
setDialogCompanyId(null);
|
||||
setStagedFiles([]);
|
||||
setIsFileDragOver(false);
|
||||
setCompanyOpen(false);
|
||||
executionWorkspaceDefaultProjectId.current = null;
|
||||
}
|
||||
@@ -453,6 +561,7 @@ export function NewIssueDialog() {
|
||||
: null;
|
||||
createIssue.mutate({
|
||||
companyId: effectiveCompanyId,
|
||||
stagedFiles,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
@@ -472,22 +581,70 @@ export function NewIssueDialog() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachImage(evt: ChangeEvent<HTMLInputElement>) {
|
||||
const file = evt.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
const name = file.name || "image";
|
||||
setDescription((prev) => {
|
||||
const suffix = ``;
|
||||
return prev ? `${prev}\n\n${suffix}` : suffix;
|
||||
});
|
||||
} finally {
|
||||
if (attachInputRef.current) attachInputRef.current.value = "";
|
||||
function stageFiles(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
setStagedFiles((current) => {
|
||||
const next = [...current];
|
||||
for (const file of files) {
|
||||
if (isTextDocumentFile(file)) {
|
||||
const baseName = fileBaseName(file.name);
|
||||
const documentKey = createUniqueDocumentKey(slugifyDocumentKey(baseName), next);
|
||||
next.push({
|
||||
id: `${file.name}:${file.size}:${file.lastModified}:${documentKey}`,
|
||||
file,
|
||||
kind: "document",
|
||||
documentKey,
|
||||
title: titleizeFilename(baseName),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
next.push({
|
||||
id: `${file.name}:${file.size}:${file.lastModified}`,
|
||||
file,
|
||||
kind: "attachment",
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleStageFilesPicked(evt: ChangeEvent<HTMLInputElement>) {
|
||||
stageFiles(Array.from(evt.target.files ?? []));
|
||||
if (stageFileInputRef.current) {
|
||||
stageFileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const hasDraft = title.trim().length > 0 || description.trim().length > 0;
|
||||
function handleFileDragEnter(evt: DragEvent<HTMLDivElement>) {
|
||||
if (!evt.dataTransfer.types.includes("Files")) return;
|
||||
evt.preventDefault();
|
||||
setIsFileDragOver(true);
|
||||
}
|
||||
|
||||
function handleFileDragOver(evt: DragEvent<HTMLDivElement>) {
|
||||
if (!evt.dataTransfer.types.includes("Files")) return;
|
||||
evt.preventDefault();
|
||||
evt.dataTransfer.dropEffect = "copy";
|
||||
setIsFileDragOver(true);
|
||||
}
|
||||
|
||||
function handleFileDragLeave(evt: DragEvent<HTMLDivElement>) {
|
||||
if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
|
||||
setIsFileDragOver(false);
|
||||
}
|
||||
|
||||
function handleFileDrop(evt: DragEvent<HTMLDivElement>) {
|
||||
if (!evt.dataTransfer.files.length) return;
|
||||
evt.preventDefault();
|
||||
setIsFileDragOver(false);
|
||||
stageFiles(Array.from(evt.dataTransfer.files));
|
||||
}
|
||||
|
||||
function removeStagedFile(id: string) {
|
||||
setStagedFiles((current) => current.filter((file) => file.id !== id));
|
||||
}
|
||||
|
||||
const hasDraft = title.trim().length > 0 || description.trim().length > 0 || stagedFiles.length > 0;
|
||||
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
||||
const currentPriority = priorities.find((p) => p.value === priority);
|
||||
const currentAssignee = selectedAssigneeAgentId
|
||||
@@ -541,6 +698,8 @@ export function NewIssueDialog() {
|
||||
const canDiscardDraft = hasDraft || hasSavedDraft;
|
||||
const createIssueErrorMessage =
|
||||
createIssue.error instanceof Error ? createIssue.error.message : "Failed to create issue. Try again.";
|
||||
const stagedDocuments = stagedFiles.filter((file) => file.kind === "document");
|
||||
const stagedAttachments = stagedFiles.filter((file) => file.kind === "attachment");
|
||||
|
||||
const handleProjectChange = useCallback((nextProjectId: string) => {
|
||||
setProjectId(nextProjectId);
|
||||
@@ -938,20 +1097,103 @@ export function NewIssueDialog() {
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className={cn("px-4 pb-2 overflow-y-auto min-h-0 border-t border-border/60 pt-3", expanded ? "flex-1" : "")}>
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
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;
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn("px-4 pb-2 overflow-y-auto min-h-0 border-t border-border/60 pt-3", expanded ? "flex-1" : "")}
|
||||
onDragEnter={handleFileDragEnter}
|
||||
onDragOver={handleFileDragOver}
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md transition-colors",
|
||||
isFileDragOver && "bg-accent/20",
|
||||
)}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
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;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{stagedFiles.length > 0 ? (
|
||||
<div className="mt-4 space-y-3 rounded-lg border border-border/70 p-3">
|
||||
{stagedDocuments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Documents</div>
|
||||
<div className="space-y-2">
|
||||
{stagedDocuments.map((file) => (
|
||||
<div key={file.id} className="flex items-start justify-between gap-3 rounded-md border border-border/70 px-3 py-2">
|
||||
<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">
|
||||
{file.documentKey}
|
||||
</span>
|
||||
<span className="truncate text-sm">{file.file.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
<span>{file.title || file.file.name}</span>
|
||||
<span>•</span>
|
||||
<span>{formatFileSize(file.file)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={() => removeStagedFile(file.id)}
|
||||
disabled={createIssue.isPending}
|
||||
title="Remove document"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{stagedAttachments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Attachments</div>
|
||||
<div className="space-y-2">
|
||||
{stagedAttachments.map((file) => (
|
||||
<div key={file.id} className="flex items-start justify-between gap-3 rounded-md border border-border/70 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm">{file.file.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{file.file.type || "application/octet-stream"} • {formatFileSize(file.file)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={() => removeStagedFile(file.id)}
|
||||
disabled={createIssue.isPending}
|
||||
title="Remove attachment"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Property chips bar */}
|
||||
@@ -1021,21 +1263,21 @@ export function NewIssueDialog() {
|
||||
Labels
|
||||
</button>
|
||||
|
||||
{/* Attach image chip */}
|
||||
<input
|
||||
ref={attachInputRef}
|
||||
ref={stageFileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
accept={STAGED_FILE_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={handleAttachImage}
|
||||
onChange={handleStageFilesPicked}
|
||||
multiple
|
||||
/>
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground"
|
||||
onClick={() => attachInputRef.current?.click()}
|
||||
disabled={uploadDescriptionImage.isPending}
|
||||
onClick={() => stageFileInputRef.current?.click()}
|
||||
disabled={createIssue.isPending}
|
||||
>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{uploadDescriptionImage.isPending ? "Uploading..." : "Image"}
|
||||
Upload
|
||||
</button>
|
||||
|
||||
{/* More (dates) */}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const help: Record<string, string> = {
|
||||
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
||||
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, spawned process, or generic HTTP webhook.",
|
||||
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
|
||||
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
||||
promptTemplate: "Sent on every heartbeat. Keep this small and dynamic. Use it for current-task framing, not large static instructions. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} and other template variables.",
|
||||
model: "Override the default model used by the adapter.",
|
||||
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
|
||||
chrome: "Enable Claude's Chrome integration by passing --chrome.",
|
||||
@@ -44,7 +44,7 @@ export const help: Record<string, string> = {
|
||||
args: "Command-line arguments, comma-separated.",
|
||||
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
|
||||
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
|
||||
bootstrapPrompt: "Optional prompt prepended on the first run to bootstrap the agent's environment or habits.",
|
||||
bootstrapPrompt: "Only sent when Paperclip starts a fresh session. Use this for stable setup guidance that should not be repeated on every heartbeat.",
|
||||
payloadTemplateJson: "Optional JSON merged into remote adapter request payloads before Paperclip adds its standard wake and workspace fields.",
|
||||
webhookUrl: "The URL that receives POST requests when the agent is invoked.",
|
||||
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
|
||||
|
||||
Reference in New Issue
Block a user