feat(ui): prototype issue thread interactions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-21 17:31:02 -05:00
1 parent 014aa0eb2d
commit 98dab55fe0
8 files changed
+1829 -2

No files matched your search

+76 -2
View File
@@ -45,6 +45,13 @@ import {
type IssueChatTranscriptEntry,
type SegmentTiming,
} from "../lib/issue-chat-messages";
import type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
IssueThreadInteraction,
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
import { 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";
@@ -67,6 +74,7 @@ import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MentionOption, type MarkdownEditorRef } from "./MarkdownEditor";
import { Identity } from "./Identity";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
import { AgentIcon } from "./AgentIconPicker";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
import {
@@ -114,6 +122,15 @@ interface IssueChatMessageContext {
onCancelQueued?: (commentId: string) => void;
interruptingQueuedRunId?: string | null;
onImageClick?: (src: string) => void;
onAcceptInteraction?: (interaction: SuggestTasksInteraction) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction,
reason?: string,
) => Promise<void> | void;
onSubmitInteractionAnswers?: (
interaction: AskUserQuestionsInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
}
const IssueChatCtx = createContext<IssueChatMessageContext>({
@@ -211,6 +228,7 @@ interface IssueChatComposerProps {
interface IssueChatThreadProps {
comments: IssueChatComment[];
interactions?: IssueThreadInteraction[];
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
@@ -256,6 +274,15 @@ interface IssueChatThreadProps {
interruptingQueuedRunId?: string | null;
stoppingRunId?: string | null;
onImageClick?: (src: string) => void;
onAcceptInteraction?: (interaction: SuggestTasksInteraction) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction,
reason?: string,
) => Promise<void> | void;
onSubmitInteractionAnswers?: (
interaction: AskUserQuestionsInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
composerRef?: Ref<IssueChatComposerHandle>;
}
@@ -1698,7 +1725,14 @@ function IssueChatFeedbackButtons({
}
function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
const { agentMap, currentUserId, userLabelMap } = useContext(IssueChatCtx);
const {
agentMap,
currentUserId,
userLabelMap,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
} = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>;
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
const runId = typeof custom.runId === "string" ? custom.runId : null;
@@ -1717,6 +1751,27 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
to: IssueTimelineAssignee;
}
: null;
const interaction = isIssueThreadInteraction(custom.interaction)
? custom.interaction
: null;
if (custom.kind === "interaction" && interaction) {
return (
<div id={anchorId}>
<div className="py-1.5">
<IssueThreadInteractionCard
interaction={interaction}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
/>
</div>
</div>
);
}
if (custom.kind === "event" && actorName) {
const isCurrentUser = actorType === "user" && !!currentUserId && actorId === currentUserId;
@@ -2077,6 +2132,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
export function IssueChatThread({
comments,
interactions = [],
feedbackVotes = [],
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
@@ -2118,6 +2174,9 @@ export function IssueChatThread({
interruptingQueuedRunId = null,
stoppingRunId = null,
onImageClick,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
composerRef,
}: IssueChatThreadProps) {
const location = useLocation();
@@ -2173,6 +2232,7 @@ export function IssueChatThread({
() =>
buildIssueChatMessages({
comments,
interactions,
timelineEvents,
linkedRuns,
liveRuns,
@@ -2188,6 +2248,7 @@ export function IssueChatThread({
}),
[
comments,
interactions,
timelineEvents,
linkedRuns,
liveRuns,
@@ -2256,7 +2317,14 @@ export function IssueChatThread({
useEffect(() => {
const hash = location.hash;
if (!(hash.startsWith("#comment-") || hash.startsWith("#activity-") || hash.startsWith("#run-"))) return;
if (
!(
hash.startsWith("#comment-")
|| hash.startsWith("#activity-")
|| hash.startsWith("#run-")
|| hash.startsWith("#interaction-")
)
) return;
if (messages.length === 0 || hasScrolledRef.current) return;
const targetId = hash.slice(1);
const element = document.getElementById(targetId);
@@ -2286,6 +2354,9 @@ export function IssueChatThread({
onCancelQueued,
interruptingQueuedRunId,
onImageClick,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
}),
[
feedbackVoteByTargetId,
@@ -2303,6 +2374,9 @@ export function IssueChatThread({
onCancelQueued,
interruptingQueuedRunId,
onImageClick,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
],
);
@@ -0,0 +1,743 @@
import { useEffect, useMemo, useState } from "react";
import type { Agent } from "@paperclipai/shared";
import { AlertTriangle, CheckCircle2, ChevronRight, CircleDashed, GitBranch, ListChecks, Loader2, MessageSquareQuote, Sparkles, XCircle } from "lucide-react";
import { Link } from "@/lib/router";
import { formatAssigneeUserLabel } from "../lib/assignees";
import {
buildSuggestedTaskTree,
countSuggestedTaskNodes,
getQuestionAnswerLabels,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
type IssueThreadInteraction,
type SuggestTasksInteraction,
type SuggestTasksResultCreatedTask,
type SuggestedTaskDraft,
type SuggestedTaskTreeNode,
} from "../lib/issue-thread-interactions";
import { cn, formatDateTime, formatShortDate } from "../lib/utils";
import { MarkdownBody } from "./MarkdownBody";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
interface IssueThreadInteractionCardProps {
interaction: IssueThreadInteraction;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
onAcceptInteraction?: (interaction: SuggestTasksInteraction) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction,
reason?: string,
) => Promise<void> | void;
onSubmitInteractionAnswers?: (
interaction: AskUserQuestionsInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
}
function resolveActorLabel(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 formatAssigneeUserLabel(userId, currentUserId, userLabelMap) ?? "Board";
}
return "Unknown";
}
function statusLabel(status: IssueThreadInteraction["status"]) {
switch (status) {
case "pending":
return "Pending";
case "accepted":
return "Accepted";
case "rejected":
return "Rejected";
case "answered":
return "Answered";
case "expired":
return "Expired";
case "failed":
return "Failed";
default:
return status;
}
}
function statusIcon(status: IssueThreadInteraction["status"]) {
switch (status) {
case "accepted":
case "answered":
return CheckCircle2;
case "rejected":
case "failed":
return XCircle;
case "expired":
return AlertTriangle;
default:
return CircleDashed;
}
}
function statusClasses(status: IssueThreadInteraction["status"]) {
switch (status) {
case "accepted":
case "answered":
return {
shell: "border-emerald-300/70 bg-[linear-gradient(145deg,rgba(16,185,129,0.14),rgba(255,255,255,0.96)_42%,rgba(6,182,212,0.08))]",
badge: "border-emerald-400/60 bg-emerald-100/80 text-emerald-950",
};
case "rejected":
return {
shell: "border-rose-300/70 bg-[linear-gradient(145deg,rgba(244,63,94,0.12),rgba(255,255,255,0.96)_42%,rgba(251,191,36,0.08))]",
badge: "border-rose-400/60 bg-rose-100/85 text-rose-950",
};
case "failed":
case "expired":
return {
shell: "border-amber-300/70 bg-[linear-gradient(145deg,rgba(245,158,11,0.14),rgba(255,255,255,0.96)_42%,rgba(148,163,184,0.08))]",
badge: "border-amber-400/60 bg-amber-100/85 text-amber-950",
};
default:
return {
shell: "border-sky-300/70 bg-[linear-gradient(145deg,rgba(14,165,233,0.10),rgba(255,255,255,0.96)_42%,rgba(250,204,21,0.08))]",
badge: "border-sky-400/60 bg-sky-100/85 text-sky-950",
};
}
}
function TaskField({
label,
value,
tone = "default",
}: {
label: string;
value: string;
tone?: "default" | "subtle";
}) {
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em]",
tone === "default"
? "border-border/70 bg-background/80 text-foreground/75"
: "border-border/60 bg-background/60 text-muted-foreground",
)}
>
{label}: {value}
</span>
);
}
function createdTaskMap(
createdTasks: readonly SuggestTasksResultCreatedTask[] | undefined,
) {
return new Map(
(createdTasks ?? []).map((entry) => [entry.clientKey, entry] as const),
);
}
function TaskTreeNode({
node,
createdByClientKey,
agentMap,
currentUserId,
userLabelMap,
depth = 0,
}: {
node: SuggestedTaskTreeNode;
createdByClientKey: ReadonlyMap<string, SuggestTasksResultCreatedTask>;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
depth?: number;
}) {
const visibleChildren = node.children.filter((child) => !child.task.hiddenInPreview);
const hiddenChildCount = node.children
.filter((child) => child.task.hiddenInPreview)
.reduce((sum, child) => sum + countSuggestedTaskNodes(child), 0);
const createdTask = createdByClientKey.get(node.task.clientKey);
const assigneeLabel = resolveActorLabel({
agentId: node.task.assigneeAgentId,
userId: node.task.assigneeUserId,
agentMap,
currentUserId,
userLabelMap,
});
const hasExplicitAssignee = Boolean(
node.task.assigneeAgentId || node.task.assigneeUserId,
);
return (
<div className="space-y-3">
<div
className={cn(
"rounded-2xl border border-border/70 bg-background/80 p-4 shadow-[0_18px_48px_rgba(15,23,42,0.08)]",
depth > 0 && "ml-4 border-dashed shadow-none",
)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-sky-300/70 bg-sky-100/80 text-sky-900">
<Sparkles className="h-3.5 w-3.5" />
</span>
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground">
{node.task.title}
</div>
{node.task.description ? (
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{node.task.description}
</p>
) : null}
</div>
</div>
</div>
{createdTask?.issueId ? (
<Link
to={`/issues/${createdTask.identifier ?? createdTask.issueId}`}
className="inline-flex items-center gap-1 rounded-full border border-emerald-400/50 bg-emerald-100/80 px-2.5 py-1 text-[11px] font-medium text-emerald-950 transition-colors hover:bg-emerald-200/80"
>
{createdTask.identifier ?? createdTask.issueId.slice(0, 8)}
<ChevronRight className="h-3 w-3" />
</Link>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{node.task.priority ? (
<TaskField label="Priority" value={node.task.priority} />
) : null}
{hasExplicitAssignee ? (
<TaskField label="Assignee" value={assigneeLabel} />
) : null}
{node.task.billingCode ? (
<TaskField label="Billing" value={node.task.billingCode} />
) : null}
{node.task.projectId ? (
<TaskField label="Project" value={node.task.projectId} tone="subtle" />
) : null}
{(node.task.labels ?? []).map((label) => (
<TaskField key={label} label="Label" value={label} tone="subtle" />
))}
</div>
{hiddenChildCount > 0 ? (
<div className="mt-3 flex items-center gap-2 rounded-xl border border-amber-300/60 bg-amber-50/85 px-3 py-2 text-xs text-amber-900">
<GitBranch className="h-3.5 w-3.5 shrink-0" />
<span>
{hiddenChildCount === 1
? "1 follow-on task hidden in preview"
: `${hiddenChildCount} follow-on tasks hidden in preview`}
</span>
</div>
) : null}
</div>
{visibleChildren.length > 0 ? (
<div className="space-y-3 border-l border-dashed border-border/70 pl-4">
{visibleChildren.map((child) => (
<TaskTreeNode
key={child.task.clientKey}
node={child}
createdByClientKey={createdByClientKey}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
depth={depth + 1}
/>
))}
</div>
) : null}
</div>
);
}
function SuggestTasksCard({
interaction,
agentMap,
currentUserId,
userLabelMap,
onAcceptInteraction,
onRejectInteraction,
}: {
interaction: SuggestTasksInteraction;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
onAcceptInteraction?: (interaction: SuggestTasksInteraction) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction,
reason?: string,
) => Promise<void> | void;
}) {
const [rejecting, setRejecting] = useState(false);
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
const [rejectReason, setRejectReason] = useState(
interaction.result?.rejectionReason ?? "",
);
useEffect(() => {
setRejectReason(interaction.result?.rejectionReason ?? "");
if (interaction.status !== "pending") {
setRejecting(false);
setWorking(null);
}
}, [interaction.result?.rejectionReason, interaction.status]);
const roots = useMemo(
() =>
buildSuggestedTaskTree(interaction.payload.tasks).filter(
(node) => !node.task.hiddenInPreview,
),
[interaction.payload.tasks],
);
const createdByClientKey = useMemo(
() => createdTaskMap(interaction.result?.createdTasks),
[interaction.result?.createdTasks],
);
const totalTasks = interaction.payload.tasks.length;
async function handleAccept() {
if (!onAcceptInteraction) return;
setWorking("accept");
try {
await onAcceptInteraction(interaction);
} finally {
setWorking(null);
}
}
async function handleReject() {
if (!onRejectInteraction) return;
setWorking("reject");
try {
await onRejectInteraction(interaction, rejectReason.trim() || undefined);
setRejecting(false);
} finally {
setWorking(null);
}
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/70 px-2.5 py-1 font-medium uppercase tracking-[0.16em] text-foreground/70">
<Sparkles className="h-3 w-3" />
Suggested tasks
</span>
<span>{totalTasks === 1 ? "1 draft issue" : `${totalTasks} draft issues`}</span>
{interaction.payload.defaultParentId ? (
<TaskField label="Default parent" value={interaction.payload.defaultParentId} tone="subtle" />
) : null}
</div>
<div className="space-y-3">
{roots.map((root) => (
<TaskTreeNode
key={root.task.clientKey}
node={root}
createdByClientKey={createdByClientKey}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
/>
))}
</div>
{interaction.status === "rejected" && interaction.result?.rejectionReason ? (
<div className="rounded-2xl border border-rose-300/60 bg-rose-50/85 px-4 py-3 text-sm text-rose-950">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-rose-700">
Rejection reason
</div>
<p className="mt-1 leading-6">{interaction.result.rejectionReason}</p>
</div>
) : null}
{interaction.status === "pending" ? (
<div className="rounded-2xl border border-border/70 bg-background/75 p-4">
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
disabled={!onAcceptInteraction || working !== null}
onClick={() => void handleAccept()}
>
{working === "accept" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Accepting...
</>
) : (
"Accept drafts"
)}
</Button>
<Button
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null}
onClick={() => setRejecting((current) => !current)}
>
Reject
</Button>
</div>
{rejecting ? (
<div className="mt-3 space-y-3">
<Textarea
value={rejectReason}
onChange={(event) => setRejectReason(event.target.value)}
placeholder="Add a short reason for rejecting this suggestion"
className="min-h-24 bg-background text-sm"
/>
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null}
onClick={() => void handleReject()}
>
{working === "reject" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Saving...
</>
) : (
"Save rejection"
)}
</Button>
</div>
</div>
) : null}
</div>
) : null}
</div>
);
}
function QuestionOptionButton({
label,
description,
selected,
onClick,
}: {
label: string;
description?: string | null;
selected: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
className={cn(
"w-full rounded-2xl border px-4 py-3 text-left transition-colors",
selected
? "border-sky-400/70 bg-sky-50/90 shadow-[0_14px_36px_rgba(14,165,233,0.14)]"
: "border-border/70 bg-background/80 hover:border-sky-300/60 hover:bg-sky-50/60",
)}
onClick={onClick}
>
<div className="text-sm font-medium text-foreground">{label}</div>
{description ? (
<div className="mt-1 text-sm leading-6 text-muted-foreground">
{description}
</div>
) : null}
</button>
);
}
function AskUserQuestionsCard({
interaction,
onSubmitInteractionAnswers,
}: {
interaction: AskUserQuestionsInteraction;
onSubmitInteractionAnswers?: (
interaction: AskUserQuestionsInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
}) {
const [draftAnswers, setDraftAnswers] = useState<Record<string, string[]>>(() =>
Object.fromEntries(
(interaction.result?.answers ?? []).map((answer) => [
answer.questionId,
[...answer.optionIds],
]),
),
);
const [working, setWorking] = useState(false);
useEffect(() => {
setDraftAnswers(
Object.fromEntries(
(interaction.result?.answers ?? []).map((answer) => [
answer.questionId,
[...answer.optionIds],
]),
),
);
}, [interaction.result?.answers]);
const questions = interaction.payload.questions;
const requiredQuestions = questions.filter((question) => question.required);
const canSubmit = requiredQuestions.every(
(question) => (draftAnswers[question.id] ?? []).length > 0,
);
function toggleOption(questionId: string, optionId: string, selectionMode: "single" | "multi") {
setDraftAnswers((current) => {
const existing = current[questionId] ?? [];
if (selectionMode === "single") {
return { ...current, [questionId]: [optionId] };
}
const next = existing.includes(optionId)
? existing.filter((value) => value !== optionId)
: [...existing, optionId];
return { ...current, [questionId]: next };
});
}
async function handleSubmit() {
if (!onSubmitInteractionAnswers || !canSubmit) return;
setWorking(true);
try {
await onSubmitInteractionAnswers(
interaction,
questions.map((question) => ({
questionId: question.id,
optionIds: draftAnswers[question.id] ?? [],
})),
);
} finally {
setWorking(false);
}
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/70 px-2.5 py-1 font-medium uppercase tracking-[0.16em] text-foreground/70">
<MessageSquareQuote className="h-3 w-3" />
Ask user questions
</span>
<span>
{questions.length === 1
? "1 question"
: `${questions.length} questions`}
</span>
</div>
{interaction.status === "pending" ? (
<div className="space-y-4">
{questions.map((question, index) => (
<div
key={question.id}
className="rounded-2xl border border-border/70 bg-background/82 p-4 shadow-[0_18px_42px_rgba(15,23,42,0.06)]"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Question {index + 1}
</div>
<div className="mt-1 text-sm font-semibold text-foreground">
{question.prompt}
</div>
{question.helpText ? (
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{question.helpText}
</p>
) : null}
</div>
<TaskField
label={question.selectionMode === "single" ? "Pick" : "Pick many"}
value={question.required ? "Required" : "Optional"}
tone="subtle"
/>
</div>
<div className="mt-3 grid gap-3">
{question.options.map((option) => (
<QuestionOptionButton
key={option.id}
label={option.label}
description={option.description}
selected={(draftAnswers[question.id] ?? []).includes(option.id)}
onClick={() =>
toggleOption(question.id, option.id, question.selectionMode)}
/>
))}
</div>
</div>
))}
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/75 p-4">
<div className="text-sm text-muted-foreground">
Submit once after you finish the full form.
</div>
<Button
size="sm"
disabled={!onSubmitInteractionAnswers || !canSubmit || working}
onClick={() => void handleSubmit()}
>
{working ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Submitting...
</>
) : (
interaction.payload.submitLabel ?? "Submit answers"
)}
</Button>
</div>
</div>
) : (
<div className="space-y-3">
{questions.map((question) => {
const labels = getQuestionAnswerLabels({
question,
answers: interaction.result?.answers ?? [],
});
return (
<div
key={question.id}
className="rounded-2xl border border-border/70 bg-background/82 p-4"
>
<div className="text-sm font-semibold text-foreground">
{question.prompt}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{labels.length > 0 ? (
labels.map((label) => (
<TaskField key={label} label="Answer" value={label} />
))
) : (
<span className="text-sm text-muted-foreground">No answer recorded.</span>
)}
</div>
</div>
);
})}
{interaction.result?.summaryMarkdown ? (
<div className="rounded-2xl border border-emerald-300/60 bg-emerald-50/85 p-4">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-700">
Submitted summary
</div>
<MarkdownBody>{interaction.result.summaryMarkdown}</MarkdownBody>
</div>
) : null}
</div>
)}
</div>
);
}
export function IssueThreadInteractionCard({
interaction,
agentMap,
currentUserId,
userLabelMap,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
}: IssueThreadInteractionCardProps) {
const StatusIcon = statusIcon(interaction.status);
const styles = statusClasses(interaction.status);
const createdByLabel = resolveActorLabel({
agentId: interaction.createdByAgentId,
userId: interaction.createdByUserId,
agentMap,
currentUserId,
userLabelMap,
});
const resolvedByLabel =
interaction.resolvedByAgentId || interaction.resolvedByUserId
? resolveActorLabel({
agentId: interaction.resolvedByAgentId,
userId: interaction.resolvedByUserId,
agentMap,
currentUserId,
userLabelMap,
})
: null;
return (
<div className={cn("rounded-[28px] border p-5 shadow-[0_28px_70px_rgba(15,23,42,0.10)]", styles.shell)}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className={cn("inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]", styles.badge)}>
<StatusIcon className="h-3.5 w-3.5" />
{statusLabel(interaction.status)}
</span>
{interaction.continuationPolicy === "wake_assignee" ? (
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/80 px-2.5 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-foreground/70">
<ListChecks className="h-3.5 w-3.5" />
Wakes assignee
</span>
) : null}
</div>
<div className="mt-3 text-lg font-semibold text-foreground">
{interaction.title
?? (interaction.kind === "suggest_tasks"
? "Suggested task tree"
: interaction.payload.title ?? "Questions for the operator")}
</div>
{interaction.summary ? (
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
{interaction.summary}
</p>
) : null}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="rounded-2xl border border-border/70 bg-background/80 px-3 py-2 text-right text-xs text-muted-foreground">
<div className="font-medium text-foreground/80">{formatShortDate(interaction.createdAt)}</div>
<div>proposed by {createdByLabel}</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Created {formatDateTime(interaction.createdAt)}
</TooltipContent>
</Tooltip>
</div>
<div className="mt-5">
{interaction.kind === "suggest_tasks" ? (
<SuggestTasksCard
interaction={interaction}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
/>
) : (
<AskUserQuestionsCard
interaction={interaction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
/>
)}
</div>
{resolvedByLabel ? (
<div className="mt-4 border-t border-border/60 pt-3 text-xs text-muted-foreground">
Resolved by <span className="font-medium text-foreground/80">{resolvedByLabel}</span>
{interaction.resolvedAt ? ` on ${formatShortDate(interaction.resolvedAt)}` : ""}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,337 @@
import type { LiveRunForIssue } from "../api/heartbeats";
import type {
IssueChatComment,
IssueChatTranscriptEntry,
} from "../lib/issue-chat-messages";
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
import type {
AskUserQuestionsInteraction,
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
export const issueThreadInteractionFixtureMeta = {
companyId: "company-storybook",
projectId: "project-board-ui",
issueId: "issue-thread-interactions",
currentUserId: "user-board",
} as const;
function createComment(overrides: Partial<IssueChatComment>): IssueChatComment {
const createdAt = overrides.createdAt ?? new Date("2026-04-20T14:00:00.000Z");
return {
id: "comment-default",
companyId: issueThreadInteractionFixtureMeta.companyId,
issueId: issueThreadInteractionFixtureMeta.issueId,
authorAgentId: null,
authorUserId: issueThreadInteractionFixtureMeta.currentUserId,
body: "",
createdAt,
updatedAt: overrides.updatedAt ?? createdAt,
...overrides,
};
}
function createSuggestTasksInteraction(
overrides: Partial<SuggestTasksInteraction>,
): SuggestTasksInteraction {
return {
id: "interaction-suggest-default",
companyId: issueThreadInteractionFixtureMeta.companyId,
issueId: issueThreadInteractionFixtureMeta.issueId,
kind: "suggest_tasks",
title: "Suggested issue tree for the first interaction pass",
summary:
"Draft task creation stays pending until a reviewer accepts it, so the thread can preview structure without mutating the task system.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-codex",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-20T14:11:00.000Z"),
updatedAt: new Date("2026-04-20T14:11:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
defaultParentId: "PAP-1709",
tasks: [
{
clientKey: "root-design",
title: "Prototype issue-thread interaction cards",
description:
"Build render-only cards that sit in the issue feed and show suggested tasks before anything is persisted.",
priority: "high",
assigneeAgentId: "agent-codex",
billingCode: "ui-research",
labels: ["UI", "interaction"],
},
{
clientKey: "child-stories",
parentClientKey: "root-design",
title: "Add Storybook coverage for acceptance and rejection states",
description:
"Cover pending, accepted, rejected, and collapsed-child previews in a fixture-backed story.",
priority: "medium",
assigneeAgentId: "agent-qa",
labels: ["Storybook"],
},
{
clientKey: "child-mixed-thread",
parentClientKey: "root-design",
title: "Prototype the mixed thread feed",
description:
"Show comments, activity, live runs, and interaction cards in one chronological feed.",
priority: "medium",
assigneeAgentId: "agent-codex",
labels: ["Issue thread"],
},
{
clientKey: "hidden-follow-up",
parentClientKey: "child-mixed-thread",
title: "Follow-up polish on spacing and answered summaries",
description:
"Collapse this under the visible task tree so the preview proves the hidden-descendant treatment.",
priority: "low",
hiddenInPreview: true,
},
],
},
result: null,
...overrides,
};
}
function createAskUserQuestionsInteraction(
overrides: Partial<AskUserQuestionsInteraction>,
): AskUserQuestionsInteraction {
return {
id: "interaction-questions-default",
companyId: issueThreadInteractionFixtureMeta.companyId,
issueId: issueThreadInteractionFixtureMeta.issueId,
kind: "ask_user_questions",
title: "Resolve open UX decisions before Phase 1",
summary:
"This form stays local until the operator submits it, so the assignee only wakes once after the whole answer set is ready.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-codex",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-20T14:18:00.000Z"),
updatedAt: new Date("2026-04-20T14:18:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
title: "Before I wire the persistence layer, which preview behavior do you want?",
submitLabel: "Send answers",
questions: [
{
id: "collapse-depth",
prompt: "How aggressive should the suggested-task preview collapse descendant work?",
helpText:
"We need enough context to review the tree without making the feed feel like a project plan.",
selectionMode: "single",
required: true,
options: [
{
id: "visible-root",
label: "Only collapse hidden descendants",
description: "Keep top-level and visible child tasks expanded.",
},
{
id: "collapse-all",
label: "Collapse all descendants by default",
description: "Show only root tasks until the operator expands the tree.",
},
],
},
{
id: "post-submit-summary",
prompt: "What should the answered-state card emphasize after submission?",
helpText: "Pick every summary treatment that would help future reviewers.",
selectionMode: "multi",
required: true,
options: [
{
id: "answers-inline",
label: "Inline answer pills",
description: "Keep the exact operator choices visible under each question.",
},
{
id: "summary-note",
label: "Short markdown summary",
description: "Add a compact narrative summary at the bottom of the card.",
},
{
id: "resolver-meta",
label: "Resolver metadata",
description: "Show who answered and when without opening the raw thread.",
},
],
},
],
},
result: null,
...overrides,
};
}
export const pendingSuggestedTasksInteraction = createSuggestTasksInteraction({});
export const acceptedSuggestedTasksInteraction = createSuggestTasksInteraction({
id: "interaction-suggest-accepted",
status: "accepted",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:16:00.000Z"),
updatedAt: new Date("2026-04-20T14:16:00.000Z"),
result: {
version: 1,
createdTasks: [
{
clientKey: "root-design",
issueId: "issue-created-1",
identifier: "PAP-1713",
title: "Prototype issue-thread interaction cards",
},
{
clientKey: "child-stories",
issueId: "issue-created-2",
identifier: "PAP-1714",
title: "Add Storybook coverage for acceptance and rejection states",
parentIssueId: "issue-created-1",
parentIdentifier: "PAP-1713",
},
{
clientKey: "child-mixed-thread",
issueId: "issue-created-3",
identifier: "PAP-1715",
title: "Prototype the mixed thread feed",
parentIssueId: "issue-created-1",
parentIdentifier: "PAP-1713",
},
{
clientKey: "hidden-follow-up",
issueId: "issue-created-4",
identifier: "PAP-1716",
title: "Follow-up polish on spacing and answered summaries",
parentIssueId: "issue-created-3",
parentIdentifier: "PAP-1715",
},
],
},
});
export const rejectedSuggestedTasksInteraction = createSuggestTasksInteraction({
id: "interaction-suggest-rejected",
status: "rejected",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:17:00.000Z"),
updatedAt: new Date("2026-04-20T14:17:00.000Z"),
result: {
version: 1,
rejectionReason:
"Keep the first pass tighter. The hidden follow-on work is useful, but the acceptance story should stay focused on one visible root and one visible child.",
},
});
export const pendingAskUserQuestionsInteraction = createAskUserQuestionsInteraction({});
export const answeredAskUserQuestionsInteraction = createAskUserQuestionsInteraction({
id: "interaction-questions-answered",
status: "answered",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:24:00.000Z"),
updatedAt: new Date("2026-04-20T14:24:00.000Z"),
result: {
version: 1,
answers: [
{
questionId: "collapse-depth",
optionIds: ["visible-root"],
},
{
questionId: "post-submit-summary",
optionIds: ["answers-inline", "summary-note", "resolver-meta"],
},
],
summaryMarkdown: [
"- Keep visible child tasks expanded when they are part of the main review path.",
"- Preserve inline answer chips and resolver metadata in the answered state.",
"- Add a short summary note so future reviewers understand the operator's intent without replaying the form.",
].join("\n"),
},
});
export const issueThreadInteractionComments: IssueChatComment[] = [
createComment({
id: "comment-thread-board",
body: "Pressure-test first-class issue-thread interactions before we touch persistence. I want to see the cards in the real feed, not in a disconnected mock.",
createdAt: new Date("2026-04-20T14:02:00.000Z"),
updatedAt: new Date("2026-04-20T14:02:00.000Z"),
}),
createComment({
id: "comment-thread-agent",
authorAgentId: "agent-codex",
authorUserId: null,
body: "I found the existing issue chat surface and I am adding prototype-only interaction records so the Storybook review can happen before persistence work.",
createdAt: new Date("2026-04-20T14:09:00.000Z"),
updatedAt: new Date("2026-04-20T14:09:00.000Z"),
runId: "run-thread-interaction",
runAgentId: "agent-codex",
}),
];
export const issueThreadInteractionEvents: IssueTimelineEvent[] = [
{
id: "event-thread-checkout",
createdAt: new Date("2026-04-20T14:01:00.000Z"),
actorType: "user",
actorId: issueThreadInteractionFixtureMeta.currentUserId,
statusChange: {
from: "todo",
to: "in_progress",
},
},
];
export const issueThreadInteractionLiveRuns: LiveRunForIssue[] = [
{
id: "run-thread-live",
status: "running",
invocationSource: "manual",
triggerDetail: null,
startedAt: "2026-04-20T14:26:00.000Z",
finishedAt: null,
createdAt: "2026-04-20T14:26:00.000Z",
agentId: "agent-codex",
agentName: "CodexCoder",
adapterType: "codex_local",
},
];
export const issueThreadInteractionTranscriptsByRunId = new Map<
string,
readonly IssueChatTranscriptEntry[]
>([
[
"run-thread-live",
[
{
kind: "assistant",
ts: "2026-04-20T14:26:02.000Z",
text: "Wiring the prototype interaction cards into the same issue feed that already renders comments and live runs.",
},
{
kind: "thinking",
ts: "2026-04-20T14:26:04.000Z",
text: "Need to keep the payload shapes local to the UI layer so Phase 0 stays non-persistent.",
},
],
],
]);
export const mixedIssueThreadInteractions = [
acceptedSuggestedTasksInteraction,
pendingAskUserQuestionsInteraction,
];
+91
View File
@@ -7,6 +7,7 @@ import {
type IssueChatComment,
type IssueChatLinkedRun,
} from "./issue-chat-messages";
import type { SuggestTasksInteraction } from "./issue-thread-interactions";
import type { IssueTimelineEvent } from "./issue-timeline-events";
import type { LiveRunForIssue } from "../api/heartbeats";
@@ -51,6 +52,39 @@ function createComment(overrides: Partial<IssueChatComment> = {}): IssueChatComm
};
}
function createInteraction(
overrides: Partial<SuggestTasksInteraction> = {},
): SuggestTasksInteraction {
return {
id: "interaction-1",
companyId: "company-1",
issueId: "issue-1",
kind: "suggest_tasks",
title: "Suggested follow-up work",
summary: "Preview the next issue tree before accepting it.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-06T12:02:00.000Z"),
updatedAt: new Date("2026-04-06T12:02:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
tasks: [
{
clientKey: "task-1",
title: "Prototype the card",
},
],
},
result: null,
...overrides,
};
}
describe("buildAssistantPartsFromTranscript", () => {
it("maps assistant text, reasoning, and tool activity while omitting noisy stderr", () => {
const result = buildAssistantPartsFromTranscript([
@@ -343,6 +377,63 @@ describe("buildIssueChatMessages", () => {
});
});
it("merges thread interactions into the same chronological feed as comments and runs", () => {
const messages = buildIssueChatMessages({
comments: [
createComment({
id: "comment-1",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
}),
],
interactions: [
createInteraction({
id: "interaction-2",
createdAt: new Date("2026-04-06T12:02:00.000Z"),
updatedAt: new Date("2026-04-06T12:02:00.000Z"),
}),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [
{
id: "run-live-1",
status: "running",
invocationSource: "manual",
triggerDetail: null,
startedAt: "2026-04-06T12:03:00.000Z",
finishedAt: null,
createdAt: "2026-04-06T12:03:00.000Z",
agentId: "agent-1",
agentName: "CodexCoder",
adapterType: "codex_local",
},
],
transcriptsByRunId: new Map([
[
"run-live-1",
[{ kind: "assistant", ts: "2026-04-06T12:03:01.000Z", text: "Working on it." }],
],
]),
hasOutputForRun: (runId) => runId === "run-live-1",
currentUserId: "user-1",
});
expect(messages.map((message) => `${message.role}:${message.id}`)).toEqual([
"user:comment-1",
"system:interaction:interaction-2",
"assistant:run-assistant:run-live-1",
]);
expect(messages[1]).toMatchObject({
metadata: {
custom: {
kind: "interaction",
anchorId: "interaction-interaction-2",
},
},
});
});
it("keeps succeeded runs as assistant messages when transcript output exists", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
const messages = buildIssueChatMessages({
+31
View File
@@ -10,6 +10,10 @@ import type {
import type { Agent, IssueComment } from "@paperclipai/shared";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { formatAssigneeUserLabel } from "./assignees";
import {
buildIssueThreadInteractionSummary,
type IssueThreadInteraction,
} from "./issue-thread-interactions";
import type { IssueTimelineEvent } from "./issue-timeline-events";
import {
summarizeNotice,
@@ -387,6 +391,23 @@ function createTimelineEventMessage(args: {
return message;
}
function createInteractionMessage(interaction: IssueThreadInteraction) {
const message: ThreadSystemMessage = {
id: `interaction:${interaction.id}`,
role: "system",
createdAt: toDate(interaction.createdAt),
content: [{ type: "text", text: buildIssueThreadInteractionSummary(interaction) }],
metadata: {
custom: {
kind: "interaction",
anchorId: `interaction-${interaction.id}`,
interaction,
},
},
};
return message;
}
function runTimestamp(run: IssueChatLinkedRun) {
return run.finishedAt ?? run.startedAt ?? run.createdAt;
}
@@ -738,6 +759,7 @@ function createLiveRunMessage(args: {
export function buildIssueChatMessages(args: {
comments: readonly IssueChatComment[];
interactions?: readonly IssueThreadInteraction[];
timelineEvents: readonly IssueTimelineEvent[];
linkedRuns: readonly IssueChatLinkedRun[];
liveRuns: readonly LiveRunForIssue[];
@@ -754,6 +776,7 @@ export function buildIssueChatMessages(args: {
}) {
const {
comments,
interactions = [],
timelineEvents,
linkedRuns,
liveRuns,
@@ -779,6 +802,14 @@ export function buildIssueChatMessages(args: {
});
}
for (const interaction of sortByCreated(interactions)) {
orderedMessages.push({
createdAtMs: toTimestamp(interaction.createdAt),
order: 2,
message: createInteractionMessage(interaction),
});
}
for (const event of sortByCreated(timelineEvents)) {
orderedMessages.push({
createdAtMs: toTimestamp(event.createdAt),
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
buildIssueThreadInteractionSummary,
buildSuggestedTaskTree,
countSuggestedTaskNodes,
getQuestionAnswerLabels,
} from "./issue-thread-interactions";
describe("buildSuggestedTaskTree", () => {
it("preserves parent-child relationships from client keys", () => {
const roots = buildSuggestedTaskTree([
{
clientKey: "root",
title: "Root",
},
{
clientKey: "child",
parentClientKey: "root",
title: "Child",
},
{
clientKey: "grandchild",
parentClientKey: "child",
title: "Grandchild",
},
]);
expect(roots).toHaveLength(1);
expect(roots[0]?.task.clientKey).toBe("root");
expect(roots[0]?.children[0]?.task.clientKey).toBe("child");
expect(countSuggestedTaskNodes(roots[0]!)).toBe(3);
});
});
describe("issue thread interaction helpers", () => {
it("summarizes task and question interactions", () => {
expect(buildIssueThreadInteractionSummary({
id: "interaction-1",
companyId: "company-1",
issueId: "issue-1",
kind: "suggest_tasks",
status: "pending",
continuationPolicy: "wake_assignee",
createdAt: "2026-04-06T12:00:00.000Z",
updatedAt: "2026-04-06T12:00:00.000Z",
payload: {
version: 1,
tasks: [
{ clientKey: "task-1", title: "One" },
{ clientKey: "task-2", title: "Two" },
],
},
})).toBe("Suggested 2 tasks");
expect(buildIssueThreadInteractionSummary({
id: "interaction-2",
companyId: "company-1",
issueId: "issue-1",
kind: "ask_user_questions",
status: "pending",
continuationPolicy: "wake_assignee",
createdAt: "2026-04-06T12:00:00.000Z",
updatedAt: "2026-04-06T12:00:00.000Z",
payload: {
version: 1,
questions: [
{
id: "question-1",
prompt: "Pick one",
selectionMode: "single",
options: [{ id: "option-1", label: "Option 1" }],
},
],
},
})).toBe("Asked 1 question");
});
it("maps stored option ids back to labels for answered summaries", () => {
const labels = getQuestionAnswerLabels({
question: {
id: "question-1",
prompt: "Pick options",
selectionMode: "multi",
options: [
{ id: "option-1", label: "Option 1" },
{ id: "option-2", label: "Option 2" },
],
},
answers: [
{
questionId: "question-1",
optionIds: ["option-2", "option-1"],
},
],
});
expect(labels).toEqual(["Option 2", "Option 1"]);
});
});
+187
View File
@@ -0,0 +1,187 @@
export type IssueThreadInteractionStatus =
| "pending"
| "accepted"
| "rejected"
| "answered"
| "expired"
| "failed";
export type IssueThreadInteractionContinuationPolicy = "none" | "wake_assignee";
export interface IssueThreadInteractionActorFields {
createdByAgentId?: string | null;
createdByUserId?: string | null;
resolvedByAgentId?: string | null;
resolvedByUserId?: string | null;
}
export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields {
id: string;
companyId: string;
issueId: string;
sourceCommentId?: string | null;
sourceRunId?: string | null;
title?: string | null;
summary?: string | null;
status: IssueThreadInteractionStatus;
continuationPolicy: IssueThreadInteractionContinuationPolicy;
createdAt: Date | string;
updatedAt: Date | string;
resolvedAt?: Date | string | null;
}
export interface SuggestedTaskDraft {
clientKey: string;
parentClientKey?: string | null;
parentId?: string | null;
title: string;
description?: string | null;
priority?: "critical" | "high" | "medium" | "low" | null;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
projectId?: string | null;
goalId?: string | null;
billingCode?: string | null;
labels?: string[];
hiddenInPreview?: boolean;
}
export interface SuggestTasksPayload {
version: 1;
defaultParentId?: string | null;
tasks: SuggestedTaskDraft[];
}
export interface SuggestTasksResultCreatedTask {
clientKey: string;
issueId: string;
identifier?: string | null;
title?: string | null;
parentIssueId?: string | null;
parentIdentifier?: string | null;
}
export interface SuggestTasksResult {
version: 1;
createdTasks?: SuggestTasksResultCreatedTask[];
rejectionReason?: string | null;
}
export interface SuggestTasksInteraction extends IssueThreadInteractionBase {
kind: "suggest_tasks";
payload: SuggestTasksPayload;
result?: SuggestTasksResult | null;
}
export interface AskUserQuestionsQuestionOption {
id: string;
label: string;
description?: string | null;
}
export interface AskUserQuestionsQuestion {
id: string;
prompt: string;
helpText?: string | null;
selectionMode: "single" | "multi";
required?: boolean;
options: AskUserQuestionsQuestionOption[];
}
export interface AskUserQuestionsPayload {
version: 1;
title?: string | null;
submitLabel?: string | null;
questions: AskUserQuestionsQuestion[];
}
export interface AskUserQuestionsAnswer {
questionId: string;
optionIds: string[];
}
export interface AskUserQuestionsResult {
version: 1;
answers: AskUserQuestionsAnswer[];
summaryMarkdown?: string | null;
}
export interface AskUserQuestionsInteraction extends IssueThreadInteractionBase {
kind: "ask_user_questions";
payload: AskUserQuestionsPayload;
result?: AskUserQuestionsResult | null;
}
export type IssueThreadInteraction =
| SuggestTasksInteraction
| AskUserQuestionsInteraction;
export interface SuggestedTaskTreeNode {
task: SuggestedTaskDraft;
children: SuggestedTaskTreeNode[];
}
export function isIssueThreadInteraction(
value: unknown,
): value is IssueThreadInteraction {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<IssueThreadInteraction>;
return typeof candidate.id === "string"
&& typeof candidate.companyId === "string"
&& typeof candidate.issueId === "string"
&& (candidate.kind === "suggest_tasks" || candidate.kind === "ask_user_questions");
}
export function buildIssueThreadInteractionSummary(
interaction: IssueThreadInteraction,
) {
if (interaction.kind === "suggest_tasks") {
const count = interaction.payload.tasks.length;
return count === 1 ? "Suggested 1 task" : `Suggested ${count} tasks`;
}
const count = interaction.payload.questions.length;
return count === 1 ? "Asked 1 question" : `Asked ${count} questions`;
}
export function buildSuggestedTaskTree(
tasks: readonly SuggestedTaskDraft[],
): SuggestedTaskTreeNode[] {
const nodes = new Map<string, SuggestedTaskTreeNode>();
for (const task of tasks) {
nodes.set(task.clientKey, { task, children: [] });
}
const roots: SuggestedTaskTreeNode[] = [];
for (const task of tasks) {
const node = nodes.get(task.clientKey);
if (!node) continue;
const parentNode = task.parentClientKey ? nodes.get(task.parentClientKey) : null;
if (parentNode) {
parentNode.children.push(node);
continue;
}
roots.push(node);
}
return roots;
}
export function countSuggestedTaskNodes(node: SuggestedTaskTreeNode): number {
return 1 + node.children.reduce((sum, child) => sum + countSuggestedTaskNodes(child), 0);
}
export function getQuestionAnswerLabels(args: {
question: AskUserQuestionsQuestion;
answers: readonly AskUserQuestionsAnswer[];
}) {
const { question, answers } = args;
const selectedIds =
answers.find((answer) => answer.questionId === question.id)?.optionIds ?? [];
const optionLabelById = new Map(
question.options.map((option) => [option.id, option.label] as const),
);
return selectedIds
.map((optionId) => optionLabelById.get(optionId))
.filter((label): label is string => typeof label === "string");
}
@@ -0,0 +1,265 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { IssueChatThread } from "@/components/IssueChatThread";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
acceptedSuggestedTasksInteraction,
answeredAskUserQuestionsInteraction,
issueThreadInteractionComments,
issueThreadInteractionEvents,
issueThreadInteractionFixtureMeta,
issueThreadInteractionLiveRuns,
issueThreadInteractionTranscriptsByRunId,
mixedIssueThreadInteractions,
pendingAskUserQuestionsInteraction,
pendingSuggestedTasksInteraction,
rejectedSuggestedTasksInteraction,
} from "@/fixtures/issueThreadInteractionFixtures";
import type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
SuggestTasksInteraction,
} from "@/lib/issue-thread-interactions";
import { storybookAgentMap } from "../fixtures/paperclipData";
const boardUserLabels = new Map<string, string>([
[issueThreadInteractionFixtureMeta.currentUserId, "Riley Board"],
["user-product", "Mara Product"],
]);
function StoryFrame({ children }: { children: React.ReactNode }) {
return (
<div className="paperclip-story">
<main className="paperclip-story__inner space-y-6">{children}</main>
</div>
);
}
function Section({
eyebrow,
title,
children,
}: {
eyebrow: string;
title: string;
children: React.ReactNode;
}) {
return (
<section className="paperclip-story__frame overflow-hidden">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-5 py-4">
<div>
<div className="paperclip-story__label">{eyebrow}</div>
<h2 className="mt-1 text-xl font-semibold">{title}</h2>
</div>
</div>
<div className="p-5">{children}</div>
</section>
);
}
function ScenarioCard({
title,
description,
children,
}: {
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<Card className="shadow-none">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
function InteractiveSuggestedTasksCard() {
const [interaction, setInteraction] = useState<SuggestTasksInteraction>(
pendingSuggestedTasksInteraction,
);
return (
<IssueThreadInteractionCard
interaction={interaction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
onAcceptInteraction={() => setInteraction(acceptedSuggestedTasksInteraction)}
onRejectInteraction={(_interaction, reason) =>
setInteraction({
...rejectedSuggestedTasksInteraction,
result: {
version: 1,
...(rejectedSuggestedTasksInteraction.result ?? {}),
rejectionReason:
reason
|| rejectedSuggestedTasksInteraction.result?.rejectionReason
|| null,
},
})}
/>
);
}
function buildAnsweredInteraction(
answers: AskUserQuestionsAnswer[],
): AskUserQuestionsInteraction {
const labels = pendingAskUserQuestionsInteraction.payload.questions.flatMap((question) => {
const answer = answers.find((entry) => entry.questionId === question.id);
if (!answer) return [];
return question.options
.filter((option) => answer.optionIds.includes(option.id))
.map((option) => option.label);
});
return {
...answeredAskUserQuestionsInteraction,
result: {
version: 1,
answers,
summaryMarkdown: labels.map((label) => `- ${label}`).join("\n"),
},
};
}
function InteractiveAskUserQuestionsCard() {
const [interaction, setInteraction] = useState<AskUserQuestionsInteraction>(
pendingAskUserQuestionsInteraction,
);
return (
<IssueThreadInteractionCard
interaction={interaction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
onSubmitInteractionAnswers={(_interaction, answers) =>
setInteraction(buildAnsweredInteraction(answers))}
/>
);
}
const meta = {
title: "Chat & Comments/Issue Thread Interactions",
parameters: {
docs: {
description: {
component:
"Prototype-only interaction cards for `suggest_tasks` and `ask_user_questions`, shown both in isolation and inside the real `IssueChatThread` feed.",
},
},
},
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
export const ReviewSurface: Story = {
render: () => (
<StoryFrame>
<section className="paperclip-story__frame p-6">
<div className="paperclip-story__label">Thread interactions</div>
<div className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
This prototype keeps the whole system non-persistent while pressure-testing the two planned
interaction kinds directly inside the issue chat surface. The card language leans closer to
annotated review sheets than generic admin widgets so the objects feel like first-class work
artifacts in the thread.
</div>
</section>
<Section eyebrow="Suggested Tasks" title="Pending, accepted, and rejected task-tree cards">
<div className="grid gap-6 xl:grid-cols-3">
<ScenarioCard
title="Pending"
description="The draft tree stays editable and non-persistent until someone accepts or rejects it."
>
<InteractiveSuggestedTasksCard />
</ScenarioCard>
<ScenarioCard
title="Accepted"
description="Accepted state resolves to created issue links while keeping the original suggestion visible in-thread."
>
<IssueThreadInteractionCard
interaction={acceptedSuggestedTasksInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
<ScenarioCard
title="Rejected"
description="The rejection reason remains attached to the artifact so future reviewers can see why the draft was declined."
>
<IssueThreadInteractionCard
interaction={rejectedSuggestedTasksInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</div>
</Section>
<Section eyebrow="Ask User Questions" title="Pending multi-question form and answered summary">
<div className="grid gap-6 xl:grid-cols-2">
<ScenarioCard
title="Pending"
description="Answers stay local across the whole form and only wake the assignee once after final submit."
>
<InteractiveAskUserQuestionsCard />
</ScenarioCard>
<ScenarioCard
title="Answered"
description="The answered state keeps the exact choices visible and adds a compact summary note for later review."
>
<IssueThreadInteractionCard
interaction={answeredAskUserQuestionsInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</div>
</Section>
<Section eyebrow="Mixed Feed" title="Interaction cards in the real issue thread">
<ScenarioCard
title="IssueChatThread composition"
description="Comments, timeline events, accepted task suggestions, a pending question form, and an active run share the same feed without persistence work."
>
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-[linear-gradient(135deg,rgba(14,165,233,0.08),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.08),transparent_42%),var(--background)] p-5 shadow-[0_30px_80px_rgba(15,23,42,0.10)]">
<IssueChatThread
comments={issueThreadInteractionComments}
interactions={mixedIssueThreadInteractions}
timelineEvents={issueThreadInteractionEvents}
liveRuns={issueThreadInteractionLiveRuns}
transcriptsByRunId={issueThreadInteractionTranscriptsByRunId}
hasOutputForRun={(runId) => runId === "run-thread-live"}
companyId={issueThreadInteractionFixtureMeta.companyId}
projectId={issueThreadInteractionFixtureMeta.projectId}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
agentMap={storybookAgentMap}
onAdd={async () => {}}
showComposer={false}
/>
</div>
</ScenarioCard>
</Section>
</StoryFrame>
),
parameters: {
docs: {
description: {
story:
"Covers the prototype states called out in [PAP-1709](/PAP/issues/PAP-1709): suggested-task previews, collapsed descendants, rejection reasons, multi-question answers, and a mixed issue thread.",
},
},
},
};