mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
[codex] Improve issue thread review flow (#4381)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Issue detail is where operators coordinate review, approvals, and follow-up work with active runs > - That thread UI needs to surface blockers, descendants, review handoffs, and reply ergonomics clearly enough for humans to guide agent work > - Several small gaps in the issue-thread flow were making review and navigation clunkier than necessary > - This pull request improves the reply composer, descendant/blocker presentation, interaction folding, and review-request handoff plumbing together as one cohesive issue-thread workflow slice > - The benefit is a cleaner operator review loop without changing the broader task model ## What Changed - restored and refined the floating reply composer behavior in the issue thread - folded expired confirmation interactions and improved post-submit thread scrolling behavior - surfaced descendant issue context and inline blocker/paused-assignee notices on the issue detail view - tightened large-board first paint behavior in `IssuesList` - added loose review-request handoffs through the issue execution-policy/update path and covered them with tests ## Verification - `pnpm vitest run ui/src/pages/IssueDetail.test.tsx` - `pnpm vitest run server/src/__tests__/issues-service.test.ts server/src/__tests__/issue-execution-policy.test.ts` - `pnpm exec vitest run --project @paperclipai/ui ui/src/components/IssueChatThread.test.tsx ui/src/components/IssueProperties.test.tsx ui/src/components/IssuesList.test.tsx ui/src/lib/issue-tree.test.ts ui/src/api/issues.test.ts` - `pnpm exec vitest run --project @paperclipai/adapter-utils packages/adapter-utils/src/server-utils.test.ts` - `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/issue-comment-reopen-routes.test.ts -t "coerces executor handoff patches into workflow-controlled review wakes|wakes the return assignee with execution_changes_requested"` - `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/issue-execution-policy.test.ts server/src/__tests__/issues-service.test.ts` ## Visual Evidence - UI layout changes are covered by the focused issue-thread component and issue-detail tests listed above. Browser screenshots were not attachable from this automated greploop environment, so reviewers should use the running preview for final visual confirmation. ## Risks - Moderate UI-flow risk: these changes touch the issue detail experience in multiple spots, so regressions would most likely show up as thread-layout quirks or incorrect review-handoff behavior > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex GPT-5-based coding agent with tool use and code execution in the Codex CLI environment ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots or documented the visual verification path - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
35a9dc37b0
commit
7ad225a198
25 changed files with 1046 additions and 44 deletions
|
|
@ -20,6 +20,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type DragEvent as ReactDragEvent,
|
||||
type ErrorInfo,
|
||||
type Ref,
|
||||
type ReactNode,
|
||||
|
|
@ -52,7 +53,7 @@ import type {
|
|||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
import { isIssueThreadInteraction } from "../lib/issue-thread-interactions";
|
||||
import { buildIssueThreadInteractionSummary, isIssueThreadInteraction } from "../lib/issue-thread-interactions";
|
||||
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
|
||||
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -505,6 +506,11 @@ function IssueChatFallbackThread({
|
|||
|
||||
const DRAFT_DEBOUNCE_MS = 800;
|
||||
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
|
||||
const SUBMIT_SCROLL_RESERVE_VH = 0.4;
|
||||
|
||||
function hasFilePayload(evt: ReactDragEvent<HTMLDivElement>) {
|
||||
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
||||
}
|
||||
|
||||
function toIsoString(value: string | Date | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
|
|
@ -610,6 +616,23 @@ function initialsForName(name: string) {
|
|||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function formatInteractionActorLabel(args: {
|
||||
agentId?: string | null;
|
||||
userId?: string | null;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
}) {
|
||||
const { agentId, userId, agentMap, currentUserId, userLabelMap } = args;
|
||||
if (agentId) return agentMap?.get(agentId)?.name ?? agentId.slice(0, 8);
|
||||
if (userId) {
|
||||
return userLabelMap?.get(userId)
|
||||
?? formatAssigneeUserLabel(userId, currentUserId, userLabelMap)
|
||||
?? "Board";
|
||||
}
|
||||
return "System";
|
||||
}
|
||||
|
||||
export function resolveIssueChatHumanAuthor(args: {
|
||||
authorName?: string | null;
|
||||
authorUserId?: string | null;
|
||||
|
|
@ -1735,6 +1758,106 @@ function IssueChatFeedbackButtons({
|
|||
);
|
||||
}
|
||||
|
||||
function ExpiredRequestConfirmationActivity({
|
||||
message,
|
||||
anchorId,
|
||||
interaction,
|
||||
}: {
|
||||
message: ThreadMessage;
|
||||
anchorId?: string;
|
||||
interaction: RequestConfirmationInteraction;
|
||||
}) {
|
||||
const {
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
} = useContext(IssueChatCtx);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasResolvedActor = Boolean(interaction.resolvedByAgentId || interaction.resolvedByUserId);
|
||||
const actorAgentId = hasResolvedActor
|
||||
? interaction.resolvedByAgentId ?? null
|
||||
: interaction.createdByAgentId ?? null;
|
||||
const actorUserId = hasResolvedActor
|
||||
? interaction.resolvedByUserId ?? null
|
||||
: interaction.createdByUserId ?? null;
|
||||
const actorName = formatInteractionActorLabel({
|
||||
agentId: actorAgentId,
|
||||
userId: actorUserId,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
});
|
||||
const actorIcon = actorAgentId ? agentMap?.get(actorAgentId)?.icon : undefined;
|
||||
const isCurrentUser = Boolean(actorUserId && currentUserId && actorUserId === currentUserId);
|
||||
const detailsId = anchorId ? `${anchorId}-details` : `${interaction.id}-details`;
|
||||
const summary = buildIssueThreadInteractionSummary(interaction);
|
||||
|
||||
const rowContent = (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn("flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs", isCurrentUser && "justify-end")}>
|
||||
<span className="font-medium text-foreground">{actorName}</span>
|
||||
<span className="text-muted-foreground">updated this task</span>
|
||||
<a
|
||||
href={anchorId ? `#${anchorId}` : undefined}
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
{timeAgo(message.createdAt)}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border/70 bg-background/70 px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground transition-colors hover:border-border hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={detailsId}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<ChevronDown className={cn("h-3 w-3 transition-transform", expanded && "rotate-180")} />
|
||||
{expanded ? "Hide confirmation" : "Expired confirmation"}
|
||||
</button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<p className={cn("mt-1 text-xs text-muted-foreground", isCurrentUser && "text-right")}>
|
||||
{summary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div id={anchorId}>
|
||||
{isCurrentUser ? (
|
||||
<div className="flex items-start justify-end gap-2 py-1">
|
||||
{rowContent}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2.5 py-1">
|
||||
<Avatar size="sm" className="mt-0.5">
|
||||
{actorIcon ? (
|
||||
<AvatarFallback><AgentIcon icon={actorIcon} className="h-3.5 w-3.5" /></AvatarFallback>
|
||||
) : (
|
||||
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
{rowContent}
|
||||
</div>
|
||||
)}
|
||||
{expanded ? (
|
||||
<div id={detailsId} className="mt-2">
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||||
const {
|
||||
agentMap,
|
||||
|
|
@ -1767,6 +1890,16 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
|||
: null;
|
||||
|
||||
if (custom.kind === "interaction" && interaction) {
|
||||
if (interaction.kind === "request_confirmation" && interaction.status === "expired") {
|
||||
return (
|
||||
<ExpiredRequestConfirmationActivity
|
||||
message={message}
|
||||
anchorId={anchorId}
|
||||
interaction={interaction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id={anchorId}>
|
||||
<div className="py-1.5">
|
||||
|
|
@ -1921,12 +2054,15 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
const [body, setBody] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const composerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const canAcceptFiles = Boolean(onImageUpload || onAttachImage);
|
||||
|
||||
function queueViewportRestore(snapshot: ReturnType<typeof captureComposerViewportSnapshot>) {
|
||||
if (!snapshot) return;
|
||||
|
|
@ -2026,25 +2162,46 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
}
|
||||
}
|
||||
|
||||
async function attachFile(file: File) {
|
||||
if (onImageUpload && file.type.startsWith("image/")) {
|
||||
const url = await onImageUpload(file);
|
||||
const safeName = file.name.replace(/[[\]]/g, "\\$&");
|
||||
const markdown = ``;
|
||||
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
|
||||
} else if (onAttachImage) {
|
||||
await onAttachImage(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
|
||||
const file = evt.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAttaching(true);
|
||||
try {
|
||||
if (onImageUpload) {
|
||||
const url = await onImageUpload(file);
|
||||
const safeName = file.name.replace(/[[\]]/g, "\\$&");
|
||||
const markdown = ``;
|
||||
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
|
||||
} else if (onAttachImage) {
|
||||
await onAttachImage(file);
|
||||
}
|
||||
await attachFile(file);
|
||||
} finally {
|
||||
setAttaching(false);
|
||||
if (attachInputRef.current) attachInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDroppedFiles(files: FileList | null | undefined) {
|
||||
if (!files || files.length === 0) return;
|
||||
setAttaching(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
await attachFile(file);
|
||||
}
|
||||
} finally {
|
||||
setAttaching(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetDragState() {
|
||||
dragDepthRef.current = 0;
|
||||
setIsDragOver(false);
|
||||
}
|
||||
|
||||
const canSubmit = !submitting && !!body.trim();
|
||||
|
||||
if (composerDisabledReason) {
|
||||
|
|
@ -2059,7 +2216,35 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
<div
|
||||
ref={composerContainerRef}
|
||||
data-testid="issue-chat-composer"
|
||||
className="space-y-3 pt-4 pb-[calc(env(safe-area-inset-bottom)+1.5rem)]"
|
||||
className={cn(
|
||||
"relative rounded-md border border-border/70 bg-background/95 p-[15px] shadow-[0_-12px_28px_rgba(15,23,42,0.08)] backdrop-blur supports-[backdrop-filter]:bg-background/85 dark:shadow-[0_-12px_28px_rgba(0,0,0,0.28)]",
|
||||
isDragOver && "ring-2 ring-primary/60 bg-accent/10",
|
||||
)}
|
||||
onDragEnter={(evt) => {
|
||||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||||
dragDepthRef.current += 1;
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragOver={(evt) => {
|
||||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||||
evt.preventDefault();
|
||||
evt.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (!canAcceptFiles) return;
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsDragOver(false);
|
||||
}}
|
||||
onDrop={(evt) => {
|
||||
if (!canAcceptFiles) return;
|
||||
if (evt.defaultPrevented) {
|
||||
resetDragState();
|
||||
return;
|
||||
}
|
||||
evt.preventDefault();
|
||||
resetDragState();
|
||||
void handleDroppedFiles(evt.dataTransfer?.files);
|
||||
}}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
|
|
@ -2069,8 +2254,8 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
mentions={mentions}
|
||||
onSubmit={handleSubmit}
|
||||
imageUploadHandler={onImageUpload}
|
||||
bordered
|
||||
contentClassName="min-h-[72px] max-h-[28dvh] overflow-y-auto pr-1 text-sm scrollbar-auto-hide"
|
||||
bordered={false}
|
||||
contentClassName="max-h-[28dvh] overflow-y-auto pr-1 text-sm scrollbar-auto-hide"
|
||||
/>
|
||||
|
||||
{composerHint ? (
|
||||
|
|
@ -2204,6 +2389,11 @@ export function IssueChatThread({
|
|||
const composerViewportAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerViewportSnapshotRef = useRef<ReturnType<typeof captureComposerViewportSnapshot>>(null);
|
||||
const preserveComposerViewportRef = useRef(false);
|
||||
const pendingSubmitScrollRef = useRef(false);
|
||||
const lastUserMessageIdRef = useRef<string | null>(null);
|
||||
const spacerBaselineAnchorRef = useRef<string | null>(null);
|
||||
const spacerInitialReserveRef = useRef(0);
|
||||
const [bottomSpacerHeight, setBottomSpacerHeight] = useState(0);
|
||||
const displayLiveRuns = useMemo(() => {
|
||||
const deduped = new Map<string, LiveRunForIssue>();
|
||||
for (const run of liveRuns) {
|
||||
|
|
@ -2317,10 +2507,57 @@ export function IssueChatThread({
|
|||
const runtime = usePaperclipIssueRuntime({
|
||||
messages,
|
||||
isRunning,
|
||||
onSend: ({ body, reopen, reassignment }) => onAdd(body, reopen, reassignment),
|
||||
onSend: ({ body, reopen, reassignment }) => {
|
||||
pendingSubmitScrollRef.current = true;
|
||||
return onAdd(body, reopen, reassignment);
|
||||
},
|
||||
onCancel: onCancelRun,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
|
||||
const lastUserId = lastUserMessage?.id ?? null;
|
||||
|
||||
if (
|
||||
pendingSubmitScrollRef.current
|
||||
&& lastUserId
|
||||
&& lastUserId !== lastUserMessageIdRef.current
|
||||
) {
|
||||
pendingSubmitScrollRef.current = false;
|
||||
const custom = lastUserMessage?.metadata.custom as { anchorId?: unknown } | undefined;
|
||||
const anchorId = typeof custom?.anchorId === "string" ? custom.anchorId : null;
|
||||
if (anchorId) {
|
||||
const reserve = Math.round(window.innerHeight * SUBMIT_SCROLL_RESERVE_VH);
|
||||
spacerBaselineAnchorRef.current = anchorId;
|
||||
spacerInitialReserveRef.current = reserve;
|
||||
setBottomSpacerHeight(reserve);
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(anchorId);
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
lastUserMessageIdRef.current = lastUserId;
|
||||
}, [messages]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const anchorId = spacerBaselineAnchorRef.current;
|
||||
if (!anchorId || spacerInitialReserveRef.current <= 0) return;
|
||||
const userEl = document.getElementById(anchorId);
|
||||
const bottomEl = bottomAnchorRef.current;
|
||||
if (!userEl || !bottomEl) return;
|
||||
const contentBelow = Math.max(
|
||||
0,
|
||||
bottomEl.getBoundingClientRect().top - userEl.getBoundingClientRect().bottom,
|
||||
);
|
||||
const next = Math.max(0, spacerInitialReserveRef.current - contentBelow);
|
||||
setBottomSpacerHeight((prev) => (prev === next ? prev : next));
|
||||
if (next === 0) {
|
||||
spacerBaselineAnchorRef.current = null;
|
||||
spacerInitialReserveRef.current = 0;
|
||||
}
|
||||
}, [messages]);
|
||||
useLayoutEffect(() => {
|
||||
const composerElement = composerViewportAnchorRef.current;
|
||||
if (preserveComposerViewportRef.current) {
|
||||
|
|
@ -2459,15 +2696,30 @@ export function IssueChatThread({
|
|||
return <IssueChatSystemMessage key={message.id} message={message} />;
|
||||
})
|
||||
)}
|
||||
{showComposer ? (
|
||||
<div data-testid="issue-chat-thread-notices" className="space-y-2">
|
||||
<IssueBlockedNotice issueStatus={issueStatus} blockers={unresolvedBlockers} />
|
||||
<IssueAssigneePausedNotice agent={assignedAgent} />
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={bottomAnchorRef} />
|
||||
{showComposer ? (
|
||||
<div
|
||||
aria-hidden
|
||||
data-testid="issue-chat-bottom-spacer"
|
||||
style={{ height: bottomSpacerHeight }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</IssueChatErrorBoundary>
|
||||
|
||||
{showComposer ? (
|
||||
<div ref={composerViewportAnchorRef}>
|
||||
<IssueBlockedNotice issueStatus={issueStatus} blockers={unresolvedBlockers} />
|
||||
<IssueAssigneePausedNotice agent={assignedAgent} />
|
||||
<div
|
||||
ref={composerViewportAnchorRef}
|
||||
data-testid="issue-chat-composer-dock"
|
||||
className="sticky bottom-[calc(env(safe-area-inset-bottom)+20px)] z-20 space-y-2 bg-gradient-to-t from-background via-background/95 to-background/0 pt-6"
|
||||
>
|
||||
<IssueChatComposer
|
||||
ref={composerRef}
|
||||
onImageUpload={imageUploadHandler}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue