Files
wehub-resource-sync e30e75b5d4
Code Quality / Oxlint + Oxfmt (push) Waiting to run
Code Quality / Template Sync (push) Waiting to run
Code Quality / Build Changed Packages (push) Waiting to run
Code Quality / Test Changed Packages (push) Waiting to run
Deploy Expo Example / Deploy Production (push) Waiting to run
Deploy Ink Example / Deploy Production (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.12) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Waiting to run
Deploy Shadcn Registry / Deploy Production (push) Waiting to run
Template Metrics / LOC + Bundle Size (push) Waiting to run
Changesets / Create Version PR (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

350 lines
11 KiB
TypeScript

"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>
);
};