mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
feat: allow recordings in tool transitions
This commit is contained in:
parent
65c76ca7ff
commit
74dbafb055
38 changed files with 1555 additions and 692 deletions
|
|
@ -1,7 +1,14 @@
|
|||
import { Check, ChevronDown, Pause, Play, Search } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContentInline, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TextOrAudioInputProps {
|
||||
type: 'text' | 'audio';
|
||||
|
|
@ -62,36 +69,144 @@ interface RecordingSelectProps {
|
|||
* their own none/custom/audio radio) can use it directly.
|
||||
*/
|
||||
export function RecordingSelect({ value, onChange, recordings }: RecordingSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const { playingId, toggle, stop } = useAudioPlayback();
|
||||
|
||||
const selected = recordings.find((r) => String(r.id) === value);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return recordings;
|
||||
const q = search.toLowerCase();
|
||||
return recordings.filter((r) =>
|
||||
r.recording_id.toLowerCase().includes(q) ||
|
||||
r.transcript.toLowerCase().includes(q) ||
|
||||
((r.metadata?.original_filename as string) || "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [recordings, search]);
|
||||
|
||||
const handleSelect = (rec: RecordingResponseSchema) => {
|
||||
stop();
|
||||
onChange(String(rec.id));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handlePlay = async (e: React.MouseEvent, rec: RecordingResponseSchema) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await toggle(rec.recording_id, rec.storage_key, rec.storage_backend);
|
||||
} catch {
|
||||
// Ignore playback errors
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Select a pre-recorded audio file to play.
|
||||
</Label>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a recording" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{recordings.length === 0 ? (
|
||||
<SelectItem value="__empty__" disabled>
|
||||
No recordings available
|
||||
</SelectItem>
|
||||
) : (
|
||||
recordings.map((r) => (
|
||||
<SelectItem key={r.recording_id} value={r.recording_id}>
|
||||
<span className="truncate">
|
||||
{(r.metadata?.original_filename as string) || r.recording_id}
|
||||
<Popover modal open={open} onOpenChange={(v) => { if (!v) { stop(); setSearch(""); } setOpen(v); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between h-auto min-h-9 font-normal"
|
||||
>
|
||||
{selected ? (
|
||||
<span className="flex items-center gap-2 text-left">
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono shrink-0">
|
||||
{selected.recording_id}
|
||||
</code>
|
||||
<span className="text-sm">
|
||||
{selected.transcript.length > 75
|
||||
? `${selected.transcript.slice(0, 75)}…`
|
||||
: selected.transcript}
|
||||
</span>
|
||||
{r.transcript && (
|
||||
<span className="text-xs text-muted-foreground ml-2 truncate">
|
||||
— {r.transcript}
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select a recording</span>
|
||||
)}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContentInline
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
{recordings.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground text-center">
|
||||
No recordings available
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by ID, transcript, or filename..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-8 pl-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground text-center">
|
||||
No recordings match “{search}”
|
||||
</div>
|
||||
) : filtered.map((r) => {
|
||||
const filename = (r.metadata?.original_filename as string) || "";
|
||||
const isSelected = String(r.id) === value;
|
||||
const isPlaying = playingId === r.recording_id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-accent transition-colors",
|
||||
isSelected && "bg-accent"
|
||||
)}
|
||||
onClick={() => handleSelect(r)}
|
||||
>
|
||||
<Check className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
isSelected ? "opacity-100" : "opacity-0"
|
||||
)} />
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono shrink-0">
|
||||
{r.recording_id}
|
||||
</code>
|
||||
{filename && (
|
||||
<span className="text-xs text-muted-foreground shrink-0 max-w-[100px] truncate">
|
||||
{filename}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded truncate flex-1 min-w-0">
|
||||
{r.transcript}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
onClick={(e) => handlePlay(e, r)}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PopoverContentInline>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Condition</DialogTitle>
|
||||
{data?.invalid && data.validationMessage && (
|
||||
|
|
@ -82,7 +82,7 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
|
|||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-4 py-4 overflow-y-auto">
|
||||
<div className="grid gap-2">
|
||||
<Label>Condition Label</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -56,6 +56,23 @@ function DialogContent({
|
|||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
onOpenAutoFocus={e => e.preventDefault()}
|
||||
onCloseAutoFocus={() => {
|
||||
document.body.style.pointerEvents = "";
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
// Prevent the Dialog from closing when the user clicks inside a
|
||||
// portaled Radix Popover/DropdownMenu rendered on top of this Dialog.
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-radix-popper-content-wrapper]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-radix-popper-content-wrapper]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ function PopoverTrigger({
|
|||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
const popoverContentClass =
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden"
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
|
|
@ -29,20 +32,38 @@ function PopoverContent({
|
|||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
className={cn(popoverContentClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PopoverContent without a Portal wrapper. Renders inline in the DOM tree,
|
||||
* which avoids focus-trap conflicts when used inside a Dialog.
|
||||
*/
|
||||
function PopoverContentInline({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(popoverContentClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverAnchor,PopoverContent, PopoverTrigger }
|
||||
export { Popover, PopoverAnchor, PopoverContent, PopoverContentInline, PopoverTrigger }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue