chore: import upstream snapshot with attribution
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
type OpenCodeQuestionRequest,
|
||||
type QuestionAnswer,
|
||||
useOpenCodeRuntimeExtras,
|
||||
} from "@assistant-ui/react-opencode";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
CheckCircle2Icon,
|
||||
CircleHelpIcon,
|
||||
LoaderIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PendingQuestionState = {
|
||||
selectedLabels: string[];
|
||||
customAnswer: string;
|
||||
};
|
||||
|
||||
const buildEmptyQuestionState = (
|
||||
request: OpenCodeQuestionRequest,
|
||||
): PendingQuestionState[] =>
|
||||
request.questions.map(() => ({
|
||||
selectedLabels: [],
|
||||
customAnswer: "",
|
||||
}));
|
||||
|
||||
const encodeQuestionAnswers = (
|
||||
request: OpenCodeQuestionRequest,
|
||||
formState: PendingQuestionState[],
|
||||
): QuestionAnswer[] =>
|
||||
request.questions.map((question, index) => {
|
||||
const current = formState[index];
|
||||
if (!current) return [];
|
||||
|
||||
const answers = [...current.selectedLabels];
|
||||
const customValue = current.customAnswer.trim();
|
||||
const customAllowed = question.custom !== false;
|
||||
|
||||
if (customAllowed && customValue) {
|
||||
answers.push(`Other: ${customValue}`);
|
||||
}
|
||||
|
||||
return answers;
|
||||
});
|
||||
|
||||
export const getQuestionSummary = (request?: OpenCodeQuestionRequest) => {
|
||||
if (!request || request.questions.length === 0) return "Waiting for input";
|
||||
if (request.questions.length === 1) {
|
||||
return request.questions[0]?.header || "1 question";
|
||||
}
|
||||
return `${request.questions.length} questions`;
|
||||
};
|
||||
|
||||
export const OpenCodeQuestionCard = ({
|
||||
request,
|
||||
state,
|
||||
answers,
|
||||
}: {
|
||||
request: OpenCodeQuestionRequest;
|
||||
state: "pending" | "answered" | "rejected";
|
||||
answers?: readonly QuestionAnswer[];
|
||||
}) => {
|
||||
const { replyToQuestion, rejectQuestion } = useOpenCodeRuntimeExtras();
|
||||
const [formState, setFormState] = useState(() =>
|
||||
buildEmptyQuestionState(request),
|
||||
);
|
||||
const [submissionState, setSubmissionState] = useState<
|
||||
"reply" | "reject" | null
|
||||
>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isPending = state === "pending";
|
||||
|
||||
const encodedAnswers = useMemo(
|
||||
() => encodeQuestionAnswers(request, formState),
|
||||
[formState, request],
|
||||
);
|
||||
|
||||
const canSubmit =
|
||||
isPending && encodedAnswers.every((answer) => answer.length > 0);
|
||||
|
||||
const updateQuestionState = (
|
||||
index: number,
|
||||
updater: (current: PendingQuestionState) => PendingQuestionState,
|
||||
) => {
|
||||
setFormState((current) =>
|
||||
current.map((item, itemIndex) =>
|
||||
itemIndex === index ? updater(item) : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const submitAnswers = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSubmissionState("reply");
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await replyToQuestion(request.id, encodedAnswers);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to submit answers.",
|
||||
);
|
||||
setSubmissionState(null);
|
||||
}
|
||||
};
|
||||
|
||||
const rejectRequest = async () => {
|
||||
setSubmissionState("reject");
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await rejectQuestion(request.id);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to reject request.",
|
||||
);
|
||||
setSubmissionState(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="gap-4 py-4">
|
||||
<CardHeader className="px-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md p-2",
|
||||
isPending
|
||||
? "bg-sky-500/10 text-sky-700"
|
||||
: state === "rejected"
|
||||
? "bg-muted text-muted-foreground"
|
||||
: "bg-emerald-500/10 text-emerald-700",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<CircleHelpIcon className="size-4" />
|
||||
) : state === "rejected" ? (
|
||||
<XCircleIcon className="size-4" />
|
||||
) : (
|
||||
<CheckCircle2Icon className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-sm">
|
||||
{isPending
|
||||
? "Answer required"
|
||||
: state === "rejected"
|
||||
? "Question dismissed"
|
||||
: "Answers submitted"}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 text-xs">
|
||||
{getQuestionSummary(request)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={isPending ? "secondary" : "outline"}>
|
||||
{request.questions.length === 1
|
||||
? "1 prompt"
|
||||
: `${request.questions.length} prompts`}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4 px-4">
|
||||
{request.questions.map((question, index) => {
|
||||
const current = formState[index] ?? {
|
||||
selectedLabels: [],
|
||||
customAnswer: "",
|
||||
};
|
||||
const answeredValues = answers?.[index] ?? [];
|
||||
const customAllowed = question.custom !== false;
|
||||
|
||||
return (
|
||||
<section key={`${request.id}-${index}`} className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{question.header}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{question.question}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
<>
|
||||
{question.options.length > 0 ? (
|
||||
question.multiple ? (
|
||||
<div className="space-y-2">
|
||||
{question.options.map((option) => {
|
||||
const checked = current.selectedLabels.includes(
|
||||
option.label,
|
||||
);
|
||||
const optionId = `${request.id}-${index}-${option.label}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.label}
|
||||
className="flex items-start gap-3 rounded-lg border p-3 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
id={optionId}
|
||||
checked={checked}
|
||||
onCheckedChange={(nextChecked) => {
|
||||
updateQuestionState(index, (item) => ({
|
||||
...item,
|
||||
selectedLabels:
|
||||
nextChecked === true
|
||||
? [...item.selectedLabels, option.label]
|
||||
: item.selectedLabels.filter(
|
||||
(label) => label !== option.label,
|
||||
),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor={optionId}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{question.options.map((option) => {
|
||||
const selected = current.selectedLabels.includes(
|
||||
option.label,
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={option.label}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
updateQuestionState(index, (item) => ({
|
||||
...item,
|
||||
selectedLabels: [option.label],
|
||||
customAnswer: "",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{customAllowed ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${request.id}-${index}-custom`}>
|
||||
Other
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`${request.id}-${index}-custom`}
|
||||
value={current.customAnswer}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
updateQuestionState(index, (item) => ({
|
||||
...item,
|
||||
customAnswer: nextValue,
|
||||
selectedLabels:
|
||||
question.multiple || nextValue.trim().length === 0
|
||||
? item.selectedLabels
|
||||
: [],
|
||||
}));
|
||||
}}
|
||||
placeholder="Add your answer"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : state === "rejected" ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
No answer was submitted.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{answeredValues.map((answer) => (
|
||||
<Badge key={answer} variant="outline">
|
||||
{answer}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
|
||||
{isPending ? (
|
||||
<CardFooter className="flex flex-wrap gap-2 px-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submitAnswers}
|
||||
disabled={!canSubmit || submissionState !== null}
|
||||
>
|
||||
{submissionState === "reply" && (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
)}
|
||||
Submit answers
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={rejectRequest}
|
||||
disabled={submissionState !== null}
|
||||
>
|
||||
{submissionState === "reject" && (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
)}
|
||||
Dismiss
|
||||
</Button>
|
||||
{error ? (
|
||||
<div className="text-destructive flex w-full items-center gap-2 text-xs">
|
||||
<AlertCircleIcon className="size-3.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user