singularity-forge/web/components/sf/focused-panel.tsx
2026-05-05 14:46:18 +02:00

404 lines
9 KiB
TypeScript

"use client";
import {
CheckSquare,
MessageSquare,
Send,
TextCursorInput,
Type,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import {
type PendingUiRequest,
useSFWorkspaceActions,
useSFWorkspaceState,
} from "@/lib/sf-workspace-store";
import { cn } from "@/lib/utils";
function methodIcon(method: PendingUiRequest["method"]) {
switch (method) {
case "select":
return <CheckSquare className="h-4 w-4" />;
case "confirm":
return <MessageSquare className="h-4 w-4" />;
case "input":
return <TextCursorInput className="h-4 w-4" />;
case "editor":
return <Type className="h-4 w-4" />;
}
}
function methodLabel(method: PendingUiRequest["method"]): string {
switch (method) {
case "select":
return "Selection";
case "confirm":
return "Confirmation";
case "input":
return "Input";
case "editor":
return "Editor";
}
}
// --- Renderers for each blocking UI request type ---
function SelectRenderer({
request,
onSubmit,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "select" }>;
onSubmit: (value: Record<string, unknown>) => void;
disabled: boolean;
}) {
const isMulti = Boolean(request.allowMultiple);
const [singleValue, setSingleValue] = useState("");
const [multiValues, setMultiValues] = useState<Set<string>>(new Set());
const handleSubmit = () => {
if (isMulti) {
onSubmit({ value: Array.from(multiValues) });
} else {
onSubmit({ value: singleValue });
}
};
const canSubmit = isMulti ? multiValues.size > 0 : singleValue !== "";
if (isMulti) {
return (
<div className="space-y-4">
<div className="space-y-2">
{request.options.map((option) => {
const optionId = `select-${option}`;
return (
<label
key={option}
htmlFor={optionId}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 transition-colors hover:bg-accent/40"
>
<Checkbox
id={optionId}
checked={multiValues.has(option)}
onCheckedChange={(checked) => {
const next = new Set(multiValues);
if (checked) {
next.add(option);
} else {
next.delete(option);
}
setMultiValues(next);
}}
disabled={disabled}
/>
<span className="text-sm">{option}</span>
</label>
);
})}
</div>
<Button
onClick={handleSubmit}
disabled={disabled || !canSubmit}
className="w-full"
>
<Send className="h-4 w-4" />
Submit selection ({multiValues.size})
</Button>
</div>
);
}
return (
<div className="space-y-4">
<RadioGroup
value={singleValue}
onValueChange={setSingleValue}
disabled={disabled}
>
{request.options.map((option) => (
<div
key={option}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 transition-colors hover:bg-accent/40"
>
<RadioGroupItem value={option} id={`select-${option}`} />
<Label
htmlFor={`select-${option}`}
className="cursor-pointer text-sm font-normal"
>
{option}
</Label>
</div>
))}
</RadioGroup>
<Button
onClick={handleSubmit}
disabled={disabled || !canSubmit}
className="w-full"
>
<Send className="h-4 w-4" />
Submit
</Button>
</div>
);
}
function ConfirmRenderer({
request,
onSubmit,
onCancel,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "confirm" }>;
onSubmit: (value: Record<string, unknown>) => void;
onCancel: () => void;
disabled: boolean;
}) {
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-background px-4 py-3 text-sm leading-relaxed">
{request.message}
</div>
<div className="flex gap-3">
<Button
onClick={() => onSubmit({ value: true })}
disabled={disabled}
className="flex-1"
>
Confirm
</Button>
<Button
onClick={onCancel}
disabled={disabled}
variant="outline"
className="flex-1"
>
Cancel
</Button>
</div>
</div>
);
}
function InputRenderer({
request,
onSubmit,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "input" }>;
onSubmit: (value: Record<string, unknown>) => void;
disabled: boolean;
}) {
const [value, setValue] = useState("");
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (value.trim()) onSubmit({ value });
}}
>
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={request.placeholder || "Enter a value"}
disabled={disabled}
autoFocus
/>
<Button
type="submit"
disabled={disabled || !value.trim()}
className="w-full"
>
<Send className="h-4 w-4" />
Submit
</Button>
</form>
);
}
function EditorRenderer({
request,
onSubmit,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "editor" }>;
onSubmit: (value: Record<string, unknown>) => void;
disabled: boolean;
}) {
const [value, setValue] = useState(request.prefill || "");
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
onSubmit({ value });
}}
>
<Textarea
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={disabled}
className="min-h-[200px] font-mono text-sm"
autoFocus
/>
<Button type="submit" disabled={disabled} className="w-full">
<Send className="h-4 w-4" />
Submit
</Button>
</form>
);
}
function RequestBody({
request,
onSubmit,
onCancel,
disabled,
}: {
request: PendingUiRequest;
onSubmit: (value: Record<string, unknown>) => void;
onCancel: () => void;
disabled: boolean;
}) {
switch (request.method) {
case "select":
return (
<SelectRenderer
request={request}
onSubmit={onSubmit}
disabled={disabled}
/>
);
case "confirm":
return (
<ConfirmRenderer
request={request}
onSubmit={onSubmit}
onCancel={onCancel}
disabled={disabled}
/>
);
case "input":
return (
<InputRenderer
request={request}
onSubmit={onSubmit}
disabled={disabled}
/>
);
case "editor":
return (
<EditorRenderer
request={request}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
}
export function FocusedPanel() {
const workspace = useSFWorkspaceState();
const { respondToUiRequest, dismissUiRequest } = useSFWorkspaceActions();
const pending = workspace.pendingUiRequests;
const isOpen = pending.length > 0;
const current = pending[0] ?? null;
const isSubmitting = workspace.commandInFlight === "extension_ui_response";
const handleSubmit = (response: Record<string, unknown>) => {
if (!current) return;
void respondToUiRequest(current.id, response);
};
const handleDismiss = () => {
if (!current) return;
void dismissUiRequest(current.id);
};
// Prevent the Sheet from closing via overlay click / escape while submitting
const handleOpenChange = (open: boolean) => {
if (!open && !isSubmitting && current) {
handleDismiss();
}
};
return (
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
<SheetContent
side="right"
className="flex flex-col sm:max-w-md"
data-testid="focused-panel"
>
{current && (
<>
<SheetHeader>
<div className="flex items-center gap-2">
{methodIcon(current.method)}
<SheetTitle>
{current.title || methodLabel(current.method)}
</SheetTitle>
</div>
<SheetDescription>
<span className="flex items-center gap-2">
<span>
{methodLabel(current.method)} requested by the agent
</span>
{pending.length > 1 && (
<span
className={cn(
"inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-semibold text-background",
)}
data-testid="focused-panel-queue-badge"
>
+{pending.length - 1}
</span>
)}
</span>
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 py-2">
<RequestBody
request={current}
onSubmit={handleSubmit}
onCancel={handleDismiss}
disabled={isSubmitting}
/>
</div>
<SheetFooter>
<Button
variant="ghost"
size="sm"
onClick={handleDismiss}
disabled={isSubmitting}
className="text-muted-foreground"
>
Dismiss
</Button>
</SheetFooter>
</>
)}
</SheetContent>
</Sheet>
);
}