mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
feat(ui): prototype issue thread interactions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
014aa0eb2d
commit
98dab55fe0
8 files changed
+1829
-2
No files matched your search
@@ -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,
|
||||
];
|
||||
@@ -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({
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user