The live screen opens wide beside the chat while the bot works on it or asks for a takeover, a file the bot presents as its result opens next to the conversation, and a question that scrolled away gets a "Needs you" pill back to it. Shared core rules derive the focus from signals clients already have (pointer events, takeover requests, a new `present` flag on attach_file, pending asks), move the pane only on focus edges, never replace a pane the user opened, and learn per bot whether the screen opens on its own. Pane widths persist per device; mobile shows a pill that opens the computer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7VSRHHzyt2KwoCkUkZ2Gc
207 lines
7.1 KiB
TypeScript
207 lines
7.1 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 { 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>
|
|
) : askActions?.length ? (
|
|
<div className="mt-3.5 space-y-1.5">
|
|
{askActions.map((action) => (
|
|
<Button
|
|
key={action.id}
|
|
variant={approvalActions && action.id === "allow" ? "default" : "outline"}
|
|
className="h-auto w-full justify-start whitespace-normal px-3.5 py-3 text-start font-normal"
|
|
disabled={submitting}
|
|
onClick={() => void submitAnswer(action.id)}
|
|
>
|
|
{pendingAction === action.id ? (
|
|
<Trans>Sending…</Trans>
|
|
) : approvalActions ? (
|
|
approvalActionLabel(action.id, action.label, action.outcome)
|
|
) : (
|
|
action.label
|
|
)}
|
|
</Button>
|
|
))}
|
|
</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`}
|
|
/>
|
|
<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>
|
|
) : (
|
|
<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 className="mt-3 text-ui-body-sm text-destructive">{error}</p> : null}
|
|
</div>
|
|
);
|
|
}
|