mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
Refine issue workflow surfaces and live updates
This commit is contained in:
parent
b4a58ba8a6
commit
03dff1a29a
48 changed files with 2800 additions and 1163 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import type {
|
||||
Agent,
|
||||
|
|
@ -631,7 +631,7 @@ const TimelineList = memo(function TimelineList({
|
|||
);
|
||||
});
|
||||
|
||||
export const CommentThread = memo(function CommentThread({
|
||||
export function CommentThread({
|
||||
comments,
|
||||
queuedComments = [],
|
||||
linkedApprovals = [],
|
||||
|
|
@ -662,9 +662,17 @@ export const CommentThread = memo(function CommentThread({
|
|||
interruptingQueuedRunId = null,
|
||||
composerDisabledReason = null,
|
||||
}: CommentThreadProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
||||
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
|
||||
const [votingTargetId, setVotingTargetId] = useState<string | null>(null);
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const location = useLocation();
|
||||
const hasScrolledRef = useRef(false);
|
||||
|
||||
|
|
@ -730,6 +738,29 @@ export const CommentThread = memo(function CommentThread({
|
|||
}));
|
||||
}, [agentMap, providedMentions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
setBody(loadDraft(draftKey));
|
||||
}, [draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
draftTimer.current = setTimeout(() => {
|
||||
saveDraft(draftKey, body);
|
||||
}, DRAFT_DEBOUNCE_MS);
|
||||
}, [body, draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
}, [effectiveSuggestedAssigneeValue]);
|
||||
|
||||
// Scroll to comment when URL hash matches #comment-{id}
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
|
|
@ -748,25 +779,72 @@ export const CommentThread = memo(function CommentThread({
|
|||
}
|
||||
}, [location.hash, comments, queuedComments]);
|
||||
|
||||
const handleFeedbackVote = useCallback(
|
||||
async (
|
||||
commentId: string,
|
||||
vote: FeedbackVoteValue,
|
||||
options?: { allowSharing?: boolean; reason?: string },
|
||||
) => {
|
||||
if (!onVote) return;
|
||||
setVotingTargetId(commentId);
|
||||
try {
|
||||
await onVote(commentId, vote, options);
|
||||
} finally {
|
||||
setVotingTargetId(null);
|
||||
}
|
||||
},
|
||||
[onVote],
|
||||
);
|
||||
async function handleSubmit() {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
||||
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
|
||||
const submittedBody = trimmed;
|
||||
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
try {
|
||||
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setReopen(true);
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
} catch {
|
||||
setBody((current) =>
|
||||
restoreSubmittedCommentDraft({
|
||||
currentBody: current,
|
||||
submittedBody,
|
||||
}),
|
||||
);
|
||||
// Parent mutation handlers surface the failure and the draft is restored for retry.
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
|
||||
const file = evt.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAttaching(true);
|
||||
try {
|
||||
if (imageUploadHandler) {
|
||||
const url = await imageUploadHandler(file);
|
||||
const safeName = file.name.replace(/[[\]]/g, "\\$&");
|
||||
const markdown = ``;
|
||||
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
|
||||
} else if (onAttachImage) {
|
||||
await onAttachImage(file);
|
||||
}
|
||||
} finally {
|
||||
setAttaching(false);
|
||||
if (attachInputRef.current) attachInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFeedbackVote(
|
||||
commentId: string,
|
||||
vote: FeedbackVoteValue,
|
||||
options?: { allowSharing?: boolean; reason?: string },
|
||||
) {
|
||||
if (!onVote) return;
|
||||
setVotingTargetId(commentId);
|
||||
try {
|
||||
await onVote(commentId, vote, options);
|
||||
} finally {
|
||||
setVotingTargetId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = !submitting && !!body.trim();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Timeline ({timeline.length + queuedComments.length})</h3>
|
||||
|
||||
const timelineSection = useMemo(
|
||||
() => (
|
||||
<TimelineList
|
||||
timeline={timeline}
|
||||
agentMap={agentMap}
|
||||
|
|
@ -783,21 +861,6 @@ export const CommentThread = memo(function CommentThread({
|
|||
highlightCommentId={highlightCommentId}
|
||||
feedbackTermsUrl={feedbackTermsUrl}
|
||||
/>
|
||||
),
|
||||
[
|
||||
timeline, agentMap, currentUserId, companyId, projectId,
|
||||
onApproveApproval, onRejectApproval, pendingApprovalAction,
|
||||
feedbackVoteByTargetId, feedbackDataSharingPreference,
|
||||
onVote, handleFeedbackVote, votingTargetId, highlightCommentId,
|
||||
feedbackTermsUrl,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Timeline ({timeline.length + queuedComments.length})</h3>
|
||||
|
||||
{timelineSection}
|
||||
|
||||
{liveRunSlot}
|
||||
|
||||
|
|
@ -840,216 +903,92 @@ export const CommentThread = memo(function CommentThread({
|
|||
{composerDisabledReason}
|
||||
</div>
|
||||
) : (
|
||||
<CommentComposer
|
||||
onAdd={onAdd}
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={effectiveSuggestedAssigneeValue}
|
||||
agentMap={agentMap}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="Leave a comment..."
|
||||
mentions={mentions}
|
||||
onSubmit={handleSubmit}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
contentClassName="min-h-[60px] text-sm"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{(imageUploadHandler || onAttachImage) && (
|
||||
<div className="mr-auto flex items-center gap-3">
|
||||
<input
|
||||
ref={attachInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleAttachFile}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => attachInputRef.current?.click()}
|
||||
disabled={attaching}
|
||||
title="Attach image"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopen}
|
||||
onChange={(e) => setReopen(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Re-open
|
||||
</label>
|
||||
{enableReassign && reassignOptions.length > 0 && (
|
||||
<InlineEntitySelector
|
||||
value={reassignTarget}
|
||||
options={reassignOptions}
|
||||
placeholder="Assignee"
|
||||
noneLabel="No assignee"
|
||||
searchPlaceholder="Search assignees..."
|
||||
emptyMessage="No assignees found."
|
||||
onChange={setReassignTarget}
|
||||
className="text-xs h-8"
|
||||
renderTriggerValue={(option) => {
|
||||
if (!option) return <span className="text-muted-foreground">Assignee</span>;
|
||||
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
||||
const agent = agentId ? agentMap?.get(agentId) : null;
|
||||
return (
|
||||
<>
|
||||
{agent ? (
|
||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
||||
const agent = agentId ? agentMap?.get(agentId) : null;
|
||||
return (
|
||||
<>
|
||||
{agent ? (
|
||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{submitting ? "Posting..." : "Comment"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CommentThread.displayName = "CommentThread";
|
||||
|
||||
/* ---- Isolated Composer (body state lives here, not in CommentThread) ---- */
|
||||
|
||||
interface CommentComposerProps {
|
||||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
||||
mentions: MentionOption[];
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
onAttachImage?: (file: File) => Promise<void>;
|
||||
draftKey?: string;
|
||||
enableReassign: boolean;
|
||||
reassignOptions: InlineEntityOption[];
|
||||
currentAssigneeValue: string;
|
||||
suggestedAssigneeValue: string;
|
||||
agentMap?: Map<string, Agent>;
|
||||
}
|
||||
|
||||
const CommentComposer = memo(function CommentComposer({
|
||||
onAdd,
|
||||
mentions,
|
||||
imageUploadHandler,
|
||||
onAttachImage,
|
||||
draftKey,
|
||||
enableReassign,
|
||||
reassignOptions,
|
||||
currentAssigneeValue,
|
||||
suggestedAssigneeValue,
|
||||
agentMap,
|
||||
}: CommentComposerProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const [reassignTarget, setReassignTarget] = useState(suggestedAssigneeValue);
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
setBody(loadDraft(draftKey));
|
||||
}, [draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
draftTimer.current = setTimeout(() => {
|
||||
saveDraft(draftKey, body);
|
||||
}, DRAFT_DEBOUNCE_MS);
|
||||
}, [body, draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setReassignTarget(suggestedAssigneeValue);
|
||||
}, [suggestedAssigneeValue]);
|
||||
|
||||
async function handleSubmit() {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
||||
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
|
||||
const submittedBody = trimmed;
|
||||
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
try {
|
||||
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setReopen(true);
|
||||
setReassignTarget(suggestedAssigneeValue);
|
||||
} catch {
|
||||
setBody((current) =>
|
||||
restoreSubmittedCommentDraft({
|
||||
currentBody: current,
|
||||
submittedBody,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
|
||||
const file = evt.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAttaching(true);
|
||||
try {
|
||||
if (imageUploadHandler) {
|
||||
const url = await imageUploadHandler(file);
|
||||
const safeName = file.name.replace(/[[\]]/g, "\\$&");
|
||||
const markdown = ``;
|
||||
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
|
||||
} else if (onAttachImage) {
|
||||
await onAttachImage(file);
|
||||
}
|
||||
} finally {
|
||||
setAttaching(false);
|
||||
if (attachInputRef.current) attachInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = !submitting && !!body.trim();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="Leave a comment..."
|
||||
mentions={mentions}
|
||||
onSubmit={handleSubmit}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
contentClassName="min-h-[60px] text-sm"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{(imageUploadHandler || onAttachImage) && (
|
||||
<div className="mr-auto flex items-center gap-3">
|
||||
<input
|
||||
ref={attachInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleAttachFile}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => attachInputRef.current?.click()}
|
||||
disabled={attaching}
|
||||
title="Attach image"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopen}
|
||||
onChange={(e) => setReopen(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Re-open
|
||||
</label>
|
||||
{enableReassign && reassignOptions.length > 0 && (
|
||||
<InlineEntitySelector
|
||||
value={reassignTarget}
|
||||
options={reassignOptions}
|
||||
placeholder="Assignee"
|
||||
noneLabel="No assignee"
|
||||
searchPlaceholder="Search assignees..."
|
||||
emptyMessage="No assignees found."
|
||||
onChange={setReassignTarget}
|
||||
className="text-xs h-8"
|
||||
renderTriggerValue={(option) => {
|
||||
if (!option) return <span className="text-muted-foreground">Assignee</span>;
|
||||
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
||||
const agent = agentId ? agentMap?.get(agentId) : null;
|
||||
return (
|
||||
<>
|
||||
{agent ? (
|
||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
||||
const agent = agentId ? agentMap?.get(agentId) : null;
|
||||
return (
|
||||
<>
|
||||
{agent ? (
|
||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{submitting ? "Posting..." : "Comment"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue