250 lines
8.8 KiB
TypeScript

import { t } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import { ChatMarkdown } from "@rakazo/chat-ui/web";
import type { ThreadMessage } from "@rakazo/contracts";
import { isApprovalAskBlock, isSecretAskBlock, selectedAskActionLabel } from "@rakazo/core";
import { Button, cn, Input } from "@rakazo/ui-web";
import { ArrowRight } from "lucide-react";
import { useState } from "react";
export type AskBlock = Extract<ThreadMessage["blocks"][number], { kind: "ask" }>;
function formatAnsweredState(
answer: string | undefined,
approval: boolean,
secret: boolean,
outcome?: "created" | "cancelled",
actions?: AskBlock["actions"],
): string {
if (secret) return t`Saved`;
if (!answer) return t`Answered`;
if (!approval) return t`Answered: ${selectedAskActionLabel(answer, actions)}`;
if (outcome === "created") return t`Created`;
if (outcome === "cancelled") return t`Cancelled`;
if (answer === "allow") return t`Allowed once`;
if (answer === "always") return t`Always allowed`;
if (answer === "deny") return t`Denied`;
return t`Answered: ${answer}`;
}
function approvalActionLabel(
id: string,
fallback: string,
outcome?: "created" | "cancelled",
): string {
if (outcome === "created") return t`Create space`;
if (outcome === "cancelled") return t`Cancel`;
if (id === "allow") return t`Allow once`;
if (id === "always") return t`Always allow this tool`;
if (id === "deny") return t`Deny`;
return fallback;
}
function secretFieldLabel(purpose: AskBlock["purpose"]): string {
if (purpose === "password") return t`Password`;
if (purpose === "api_key") return t`API key`;
return t`Code`;
}
export function AskCard({
block,
canAnswer,
onAnswer,
className,
}: {
block: AskBlock;
canAnswer: boolean;
onAnswer: (text: string) => Promise<void>;
className?: string;
}) {
const { t } = useLingui();
const [editing, setEditing] = useState(false);
const [answer, setAnswer] = useState("");
const [pendingAction, setPendingAction] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const submitting = pendingAction !== null;
const approvalActions = isApprovalAskBlock(block) ? block.actions : undefined;
const askActions = block.actions;
const secretInput = isSecretAskBlock(block);
const secretLabel = secretFieldLabel(block.purpose);
async function submitAnswer(value: string) {
if (submitting) return;
if (secretInput ? value.length === 0 : !value.trim()) return;
const submitValue = secretInput ? value : value.trim();
setPendingAction(secretInput ? "submit" : submitValue);
setError(null);
if (secretInput) setAnswer("");
try {
await onAnswer(submitValue);
} catch (err) {
setError(
!secretInput && err instanceof Error ? err.message : t`Could not submit this answer`,
);
} finally {
setPendingAction(null);
}
}
return (
<div
data-ask-card
data-testid={secretInput ? "secret-ask-card" : undefined}
className={cn("max-w-[74%] rounded-2xl border border-border bg-card px-5 py-4", className)}
>
<div className="text-ui-body leading-[1.5] text-foreground">
<ChatMarkdown>{block.text}</ChatMarkdown>
</div>
{secretInput && block.credential ? (
<div className="mt-2 break-all text-ui-body-sm text-muted-foreground">
{block.credential.origin}
</div>
) : null}
{block.detail && !secretInput ? (
<pre className="mt-3 max-h-72 overflow-auto whitespace-pre-wrap break-words rounded-xl bg-muted px-3.5 py-3 font-mono text-ui-body-sm leading-[1.7] text-muted-foreground">
{block.detail}
</pre>
) : null}
{block.status === "answered" ? (
<div className="mt-3.5 text-ui-body font-medium text-success">
{formatAnsweredState(
block.answer,
Boolean(approvalActions),
secretInput,
approvalActions?.find((action) => action.id === block.answer)?.outcome,
askActions,
)}
</div>
) : !canAnswer ? (
<div className="mt-3.5 text-ui-body font-medium text-muted-foreground">
<Trans>No longer active</Trans>
</div>
) : secretInput ? (
<form
className="mt-3.5 flex flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
void submitAnswer(answer);
}}
>
<Input
aria-label={secretLabel}
type="password"
autoComplete="off"
spellCheck={false}
disabled={submitting}
value={answer}
onChange={(event) => setAnswer(event.target.value)}
placeholder={secretLabel}
/>
<Button type="submit" className="self-start" disabled={answer.length === 0 || submitting}>
{submitting ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
</Button>
</form>
) : editing ? (
<form
className="mt-3.5 flex flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
void submitAnswer(answer);
}}
>
<Input
aria-label={t`Answer`}
value={answer}
onChange={(event) => setAnswer(event.target.value)}
placeholder={t`Type your answer`}
autoFocus
/>
<div className="flex gap-2">
<Button type="submit" disabled={!answer.trim() || submitting}>
{submitting ? <Trans>Sending…</Trans> : <Trans>Send answer</Trans>}
</Button>
<Button
variant="outline"
disabled={submitting}
onClick={() => {
setAnswer("");
setEditing(false);
}}
>
<Trans>Cancel</Trans>
</Button>
</div>
</form>
) : askActions?.length ? (
<div className="mt-3.5 space-y-1.5">
{askActions.map((action) => {
const isPrimaryApproval = Boolean(approvalActions && action.id === "allow");
return (
<Button
key={action.id}
variant={isPrimaryApproval ? "default" : "outline"}
className={cn(
"h-auto w-full justify-between gap-3 whitespace-normal rounded-xl px-4 py-3 text-start font-medium transition-all group/ask-btn",
isPrimaryApproval
? ""
: "border-border/80 bg-muted/60 hover:border-foreground/30 hover:bg-muted active:scale-[0.99]",
)}
disabled={submitting}
onClick={() => void submitAnswer(action.id)}
>
<span
className={cn(
"flex-1 text-ui-body font-medium leading-snug",
isPrimaryApproval ? "text-primary-foreground" : "text-foreground",
)}
>
{pendingAction === action.id ? (
<Trans>Sending…</Trans>
) : approvalActions ? (
approvalActionLabel(action.id, action.label, action.outcome)
) : (
action.label
)}
</span>
<ArrowRight
strokeWidth={2}
className={cn(
"size-icon-sm shrink-0 transition-transform group-hover/ask-btn:translate-x-0.5",
isPrimaryApproval
? "text-primary-foreground/75 group-hover/ask-btn:text-primary-foreground"
: "text-muted-foreground group-hover/ask-btn:text-foreground",
)}
/>
</Button>
);
})}
{!approvalActions ? (
<div className="pt-1">
<Button
variant="ghost"
size="sm"
className="h-auto px-2 py-1 text-ui-caption text-muted-foreground hover:text-foreground"
disabled={submitting}
onClick={() => setEditing(true)}
>
<Trans>Type your answer</Trans>
</Button>
</div>
) : null}
</div>
) : (
<div className="mt-3.5 flex gap-2">
<Button disabled={submitting} onClick={() => void submitAnswer("approved")}>
{submitting ? <Trans>Sending…</Trans> : <Trans>Send it</Trans>}
</Button>
<Button variant="outline" disabled={submitting} onClick={() => setEditing(true)}>
<Trans>Edit first</Trans>
</Button>
</div>
)}
{error ? (
<p role="alert" className="mt-3 text-ui-body-sm text-destructive">
{error}
</p>
) : null}
</div>
);
}