250 lines
8.8 KiB
TypeScript
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>
|
|
);
|
|
}
|