mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +09:00
[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:
parent
014aa0eb2d
commit
a957394420
93 changed files with 10089 additions and 752 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue