[codex] Add structured issue-thread interactions (#4244)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Operators supervise that work through issues, comments, approvals,
and the board UI.
> - Some agent proposals need structured board/user decisions, not
hidden markdown conventions or heavyweight governed approvals.
> - Issue-thread interactions already provide a natural thread-native
surface for proposed tasks and questions.
> - This pull request extends that surface with request confirmations,
richer interaction cards, and agent/plugin/MCP helpers.
> - The benefit is that plan approvals and yes/no decisions become
explicit, auditable, and resumable without losing the single-issue
workflow.

## What Changed

- Added persisted issue-thread interactions for suggested tasks,
structured questions, and request confirmations.
- Added board UI cards for interaction review, selection, question
answers, and accept/reject confirmation flows.
- Added MCP and plugin SDK helpers for creating interaction cards from
agents/plugins.
- Updated agent wake instructions, onboarding assets, Paperclip skill
docs, and public docs to prefer structured confirmations for
issue-scoped decisions.
- Rebased the branch onto `public-gh/master` and renumbered branch
migrations to `0063` and `0064`; the idempotency migration uses `ADD
COLUMN IF NOT EXISTS` for old branch users.

## Verification

- `git diff --check public-gh/master..HEAD`
- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
packages/mcp-server/src/tools.test.ts
packages/shared/src/issue-thread-interactions.test.ts
ui/src/lib/issue-thread-interactions.test.ts
ui/src/lib/issue-chat-messages.test.ts
ui/src/components/IssueThreadInteractionCard.test.tsx
ui/src/components/IssueChatThread.test.tsx
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
server/src/services/issue-thread-interactions.test.ts` -> 9 files / 79
tests passed
- `pnpm -r typecheck` -> passed, including `packages/db` migration
numbering check

## Risks

- Medium: this adds a new issue-thread interaction model across
db/shared/server/ui/plugin surfaces.
- Migration risk is reduced by placing this branch after current master
migrations (`0063`, `0064`) and making the idempotency column add
idempotent for users who applied the old branch numbering.
- UI interaction behavior is covered by component tests, but this PR
does not include browser screenshots.

> 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-class coding agent runtime. Exact model ID and
context window are not exposed in this Paperclip run; tool use and local
shell/code execution were enabled.

## 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
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] 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:
Dotta 2026-04-21 20:15:11 -05:00 committed by GitHub
parent 014aa0eb2d
commit a957394420
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 10089 additions and 752 deletions

View file

@ -112,15 +112,20 @@ import {
getClosedIsolatedExecutionWorkspaceMessage,
isClosedIsolatedExecutionWorkspace,
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
type AskUserQuestionsAnswer,
type ActivityEvent,
type Agent,
type FeedbackVote,
type Issue,
type IssueAttachment,
type IssueComment,
type IssueThreadInteraction,
type RequestConfirmationInteraction,
type SuggestTasksInteraction,
} from "@paperclipai/shared";
type CommentReassignment = IssueCommentReassignment;
type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction;
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
runId?: string | null;
runAgentId?: string | null;
@ -509,6 +514,7 @@ type IssueDetailChatTabProps = {
blockedBy: Issue["blockedBy"];
comments: IssueDetailComment[];
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
interactions: IssueThreadInteraction[];
hasOlderComments: boolean;
commentsLoadingOlder: boolean;
onLoadOlderComments: () => void;
@ -538,6 +544,15 @@ type IssueDetailChatTabProps = {
onCancelQueued: (commentId: string) => void;
interruptingQueuedRunId: string | null;
onImageClick: (src: string) => void;
onAcceptInteraction: (
interaction: ActionableIssueThreadInteraction,
selectedClientKeys?: string[],
) => Promise<void>;
onRejectInteraction: (interaction: ActionableIssueThreadInteraction, reason?: string) => Promise<void>;
onSubmitInteractionAnswers: (
interaction: IssueThreadInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void>;
};
const IssueDetailChatTab = memo(function IssueDetailChatTab({
@ -549,6 +564,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
blockedBy,
comments,
locallyQueuedCommentRunIds,
interactions,
hasOlderComments,
commentsLoadingOlder,
onLoadOlderComments,
@ -574,6 +590,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onCancelQueued,
interruptingQueuedRunId,
onImageClick,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
}: IssueDetailChatTabProps) {
const { data: activity } = useQuery({
queryKey: queryKeys.issues.activity(issueId),
@ -704,6 +723,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
<IssueChatThread
composerRef={composerRef}
comments={commentsWithRunMeta}
interactions={interactions}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={feedbackTermsUrl}
@ -735,6 +755,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
interruptingQueuedRunId={interruptingQueuedRunId}
stoppingRunId={interruptingQueuedRunId}
onStopRun={onInterruptQueued}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
onSubmitInteractionAnswers={(interaction, answers) =>
onSubmitInteractionAnswers(interaction, answers)
}
onCancelRun={runningIssueRun
? async () => {
await onInterruptQueued(runningIssueRun.id);
@ -1006,6 +1031,12 @@ export function IssueDetail() {
() => flattenIssueCommentPages(commentPages?.pages),
[commentPages?.pages],
);
const { data: interactions = [] } = useQuery({
queryKey: queryKeys.issues.interactions(issueId!),
queryFn: () => issuesApi.listInteractions(issueId!),
enabled: !!issueId,
placeholderData: keepPreviousDataForSameQueryTail<IssueThreadInteraction[]>(issueId ?? "pending"),
});
const { data: attachments, isLoading: attachmentsLoading } = useQuery({
queryKey: queryKeys.issues.attachments(issueId!),
@ -1207,10 +1238,12 @@ export function IssueDetail() {
const invalidateIssueDetail = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId!) });
}, [issueId, queryClient]);
const invalidateIssueThreadLazily = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!), refetchType: "inactive" });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!), refetchType: "inactive" });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId!), refetchType: "inactive" });
}, [issueId, queryClient]);
const invalidateIssueRunState = useCallback(() => {
@ -1245,6 +1278,22 @@ export function IssueDetail() {
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
}
}, [queryClient, selectedCompanyId]);
const upsertInteractionInCache = useCallback((interaction: IssueThreadInteraction) => {
queryClient.setQueryData<IssueThreadInteraction[] | undefined>(
queryKeys.issues.interactions(issueId!),
(current) => {
const existing = current ?? [];
const next = existing.filter((entry) => entry.id !== interaction.id);
next.push(interaction);
next.sort((left, right) => {
const createdAtDelta =
new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime();
return createdAtDelta === 0 ? left.id.localeCompare(right.id) : createdAtDelta;
});
return next;
},
);
}, [issueId, queryClient]);
const applyOptimisticIssueCacheUpdate = useCallback((refs: Iterable<string>, data: Record<string, unknown>) => {
queryClient.setQueriesData<Issue>(
@ -1495,6 +1544,89 @@ export function IssueDetail() {
}
},
});
const acceptInteraction = useMutation({
mutationFn: ({
interaction,
selectedClientKeys,
}: {
interaction: ActionableIssueThreadInteraction;
selectedClientKeys?: string[];
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys }),
onSuccess: (interaction) => {
upsertInteractionInCache(interaction);
if (interaction.kind === "suggest_tasks" && resolvedCompanyId && issue?.id) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(resolvedCompanyId, issue.id) });
}
invalidateIssueDetail();
invalidateIssueCollections();
const createdCount = interaction.kind === "suggest_tasks"
? interaction.result?.createdTasks?.length ?? 0
: 0;
const skippedCount = interaction.kind === "suggest_tasks"
? interaction.result?.skippedClientKeys?.length ?? 0
: 0;
pushToast({
title: interaction.kind === "request_confirmation"
? "Request confirmed"
: skippedCount > 0
? `Accepted ${createdCount} draft${createdCount === 1 ? "" : "s"} and skipped ${skippedCount}`
: "Suggested tasks accepted",
tone: "success",
});
},
onError: (err) => {
pushToast({
title: "Accept failed",
body: err instanceof Error ? err.message : "Unable to accept the suggested tasks",
tone: "error",
});
},
});
const rejectInteraction = useMutation({
mutationFn: ({ interaction, reason }: { interaction: ActionableIssueThreadInteraction; reason?: string }) =>
issuesApi.rejectInteraction(issueId!, interaction.id, reason),
onSuccess: (interaction) => {
upsertInteractionInCache(interaction);
invalidateIssueDetail();
invalidateIssueCollections();
pushToast({
title: interaction.kind === "request_confirmation" ? "Request declined" : "Suggestion rejected",
tone: "success",
});
},
onError: (err) => {
pushToast({
title: "Reject failed",
body: err instanceof Error ? err.message : "Unable to reject the suggested tasks",
tone: "error",
});
},
});
const answerInteraction = useMutation({
mutationFn: ({
interaction,
answers,
}: {
interaction: IssueThreadInteraction;
answers: AskUserQuestionsAnswer[];
}) => issuesApi.respondToInteraction(issueId!, interaction.id, { answers }),
onSuccess: (interaction) => {
upsertInteractionInCache(interaction);
invalidateIssueDetail();
invalidateIssueCollections();
pushToast({
title: "Answers submitted",
tone: "success",
});
},
onError: (err) => {
pushToast({
title: "Submit failed",
body: err instanceof Error ? err.message : "Unable to submit answers",
tone: "error",
});
},
});
const addCommentAndReassign = useMutation({
mutationFn: ({
@ -2224,6 +2356,21 @@ export function IssueDetail() {
const handleInterruptQueuedRun = useCallback(async (runId: string) => {
await interruptQueuedComment.mutateAsync(runId);
}, [interruptQueuedComment]);
const handleAcceptInteraction = useCallback(async (
interaction: ActionableIssueThreadInteraction,
selectedClientKeys?: string[],
) => {
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys });
}, [acceptInteraction]);
const handleRejectInteraction = useCallback(async (interaction: ActionableIssueThreadInteraction, reason?: string) => {
await rejectInteraction.mutateAsync({ interaction, reason });
}, [rejectInteraction]);
const handleSubmitInteractionAnswers = useCallback(async (
interaction: IssueThreadInteraction,
answers: AskUserQuestionsAnswer[],
) => {
await answerInteraction.mutateAsync({ interaction, answers });
}, [answerInteraction]);
if (isLoading) return <IssueDetailLoadingState headerSeed={issueHeaderSeed} />;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
@ -2775,6 +2922,7 @@ export function IssueDetail() {
blockedBy={issue.blockedBy ?? []}
comments={threadComments}
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
interactions={interactions}
hasOlderComments={hasOlderComments}
commentsLoadingOlder={commentsLoadingOlder}
onLoadOlderComments={loadOlderComments}
@ -2800,6 +2948,9 @@ export function IssueDetail() {
onCancelQueued={handleCancelQueuedComment}
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
onImageClick={handleChatImageClick}
onAcceptInteraction={handleAcceptInteraction}
onRejectInteraction={handleRejectInteraction}
onSubmitInteractionAnswers={handleSubmitInteractionAnswers}
/>
) : null}
</TabsContent>