chat-messages: add hitl module with types, hooks, bundle, approval cards, and edit panel.

This commit is contained in:
CREDO23 2026-05-09 18:31:23 +02:00
parent d9ad9ca5cb
commit 9e451a5907
17 changed files with 1444 additions and 0 deletions

View file

@ -0,0 +1,82 @@
import { atom } from "jotai";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
export interface ExtraField {
label: string;
key: string;
value: string;
type: "text" | "email" | "emails" | "datetime-local" | "textarea";
}
interface HitlEditPanelState {
isOpen: boolean;
title: string;
content: string;
toolName: string;
contentFormat?: "markdown" | "html";
extraFields?: ExtraField[];
onSave:
| ((title: string, content: string, extraFieldValues?: Record<string, string>) => void)
| null;
onClose: (() => void) | null;
}
const initialState: HitlEditPanelState = {
isOpen: false,
title: "",
content: "",
toolName: "",
contentFormat: undefined,
extraFields: undefined,
onSave: null,
onClose: null,
};
export const hitlEditPanelAtom = atom<HitlEditPanelState>(initialState);
const preHitlCollapsedAtom = atom<boolean | null>(null);
export const openHitlEditPanelAtom = atom(
null,
(
get,
set,
payload: {
title: string;
content: string;
toolName: string;
contentFormat?: "markdown" | "html";
extraFields?: ExtraField[];
onSave: (title: string, content: string, extraFieldValues?: Record<string, string>) => void;
onClose?: () => void;
}
) => {
if (!get(hitlEditPanelAtom).isOpen) {
set(preHitlCollapsedAtom, get(rightPanelCollapsedAtom));
}
set(hitlEditPanelAtom, {
isOpen: true,
title: payload.title,
content: payload.content,
toolName: payload.toolName,
contentFormat: payload.contentFormat,
extraFields: payload.extraFields,
onSave: payload.onSave,
onClose: payload.onClose ?? null,
});
set(rightPanelTabAtom, "hitl-edit");
set(rightPanelCollapsedAtom, false);
}
);
export const closeHitlEditPanelAtom = atom(null, (get, set) => {
const current = get(hitlEditPanelAtom);
current.onClose?.();
set(hitlEditPanelAtom, initialState);
set(rightPanelTabAtom, "sources");
const prev = get(preHitlCollapsedAtom);
if (prev !== null) {
set(rightPanelCollapsedAtom, prev);
set(preHitlCollapsedAtom, null);
}
});

View file

@ -0,0 +1,203 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { XIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Skeleton } from "@/components/ui/skeleton";
import { useMediaQuery } from "@/hooks/use-media-query";
import { closeHitlEditPanelAtom, type ExtraField, hitlEditPanelAtom } from "./edit-panel.atom";
import { ExtraFieldsSection } from "./fields";
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
{ ssr: false, loading: () => <Skeleton className="h-64 w-full" /> }
);
/**
* The actual editable form. Controlled by atom data via the
* Desktop/Mobile shells below; isolated from layout so the same form
* renders identically in either container.
*/
export function HitlEditPanelContent({
title: initialTitle,
content: initialContent,
contentFormat,
extraFields,
onSave,
onClose,
showCloseButton = true,
}: {
title: string;
content: string;
toolName: string;
contentFormat?: "markdown" | "html";
extraFields?: ExtraField[];
onSave: (title: string, content: string, extraFieldValues?: Record<string, string>) => void;
onClose?: () => void;
showCloseButton?: boolean;
}) {
const [editedTitle, setEditedTitle] = useState(initialTitle);
const contentRef = useRef(initialContent);
const [isSaving, setIsSaving] = useState(false);
const [extraFieldValues, setExtraFieldValues] = useState<Record<string, string>>(() => {
if (!extraFields) return {};
const initial: Record<string, string> = {};
for (const field of extraFields) {
initial[field.key] = field.value;
}
return initial;
});
const handleContentChange = useCallback((content: string) => {
contentRef.current = content;
}, []);
const handleExtraFieldChange = useCallback((key: string, value: string) => {
setExtraFieldValues((prev) => ({ ...prev, [key]: value }));
}, []);
const handleSave = useCallback(() => {
if (!editedTitle.trim()) return;
setIsSaving(true);
const extras = extraFields && extraFields.length > 0 ? extraFieldValues : undefined;
onSave(editedTitle, contentRef.current, extras);
onClose?.();
}, [editedTitle, onSave, onClose, extraFields, extraFieldValues]);
return (
<>
<div className="flex items-center gap-2 px-4 py-2 shrink-0 border-b">
<input
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
placeholder="Untitled"
className="flex-1 min-w-0 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
aria-label="Page title"
/>
{onClose && showCloseButton && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close panel</span>
</Button>
)}
</div>
{extraFields && extraFields.length > 0 && (
<ExtraFieldsSection
fields={extraFields}
values={extraFieldValues}
onFieldChange={handleExtraFieldChange}
/>
)}
<div className="flex-1 overflow-hidden">
<PlateEditor
{...(contentFormat === "html"
? { html: initialContent, onHtmlChange: handleContentChange }
: { markdown: initialContent, onMarkdownChange: handleContentChange })}
readOnly={false}
preset="full"
placeholder="Start writing..."
editorVariant="default"
defaultEditing
onSave={handleSave}
hasUnsavedChanges
isSaving={isSaving}
className="[&_[role=toolbar]]:!bg-sidebar"
/>
</div>
</>
);
}
function DesktopHitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const closePanel = useSetAtom(closeHitlEditPanelAtom);
if (!panelState.isOpen || !panelState.onSave) return null;
return (
<div className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out">
<HitlEditPanelContent
title={panelState.title}
content={panelState.content}
toolName={panelState.toolName}
contentFormat={panelState.contentFormat}
extraFields={panelState.extraFields}
onSave={panelState.onSave}
onClose={closePanel}
/>
</div>
);
}
function MobileHitlEditDrawer() {
const panelState = useAtomValue(hitlEditPanelAtom);
const closePanel = useSetAtom(closeHitlEditPanelAtom);
if (!panelState.onSave) return null;
return (
<Drawer
open={panelState.isOpen}
onOpenChange={(open) => {
if (!open) closePanel();
}}
shouldScaleBackground={false}
>
<DrawerContent
className="h-[90vh] max-h-[90vh] z-80 bg-sidebar overflow-hidden"
overlayClassName="z-80"
>
<DrawerHandle />
<DrawerTitle className="sr-only">Edit {panelState.toolName}</DrawerTitle>
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<HitlEditPanelContent
title={panelState.title}
content={panelState.content}
toolName={panelState.toolName}
contentFormat={panelState.contentFormat}
extraFields={panelState.extraFields}
onSave={panelState.onSave}
onClose={closePanel}
showCloseButton={false}
/>
</div>
</DrawerContent>
</Drawer>
);
}
/**
* Entry point mounted by the right-panel layout. Renders the desktop
* panel on lg+ and the mobile drawer below; both share state via the
* ``hitlEditPanelAtom``.
*/
export function HitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (!panelState.isOpen) return null;
if (isDesktop) {
return <DesktopHitlEditPanel />;
}
return <MobileHitlEditDrawer />;
}
/**
* Entry point mounted by chat pages so the mobile drawer can render
* outside the desktop right-panel container.
*/
export function MobileHitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (isDesktop || !panelState.isOpen) return null;
return <MobileHitlEditDrawer />;
}

View file

@ -0,0 +1,112 @@
"use client";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
function parseDateTimeValue(value: string): { date: Date | undefined; time: string } {
if (!value) return { date: undefined, time: "09:00" };
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return { date: undefined, time: "09:00" };
return {
date: d,
time: format(d, "HH:mm"),
};
} catch {
return { date: undefined, time: "09:00" };
}
}
function buildLocalDateTimeString(date: Date | undefined, time: string): string {
if (!date) return "";
const [hours, minutes] = time.split(":").map(Number);
const combined = new Date(date);
combined.setHours(hours ?? 9, minutes ?? 0, 0, 0);
const y = combined.getFullYear();
const m = String(combined.getMonth() + 1).padStart(2, "0");
const d = String(combined.getDate()).padStart(2, "0");
const h = String(combined.getHours()).padStart(2, "0");
const min = String(combined.getMinutes()).padStart(2, "0");
return `${y}-${m}-${d}T${h}:${min}:00`;
}
/**
* Calendar popover + 24h time input. Emits a local ISO-like string
* (``YYYY-MM-DDThh:mm:00``) on every change. Value is parsed back into
* date + time on every render so the picker stays in sync with
* controlled props.
*/
export function DateTimePickerField({
id,
value,
onChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
}) {
const parsed = useMemo(() => parseDateTimeValue(value), [value]);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(parsed.date);
const [time, setTime] = useState(parsed.time);
const [open, setOpen] = useState(false);
const handleDateSelect = useCallback(
(day: Date | undefined) => {
setSelectedDate(day);
onChange(buildLocalDateTimeString(day, time));
setOpen(false);
},
[time, onChange]
);
const handleTimeChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value;
setTime(newTime);
onChange(buildLocalDateTimeString(selectedDate, newTime));
},
[selectedDate, onChange]
);
const displayLabel = selectedDate
? `${format(selectedDate, "MMM d, yyyy")} at ${time}`
: "Pick date & time";
return (
<div className="flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
id={id}
type="button"
className="flex-1 flex items-center gap-2 h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring"
>
<CalendarIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className={selectedDate ? "text-foreground" : "text-muted-foreground"}>
{displayLabel}
</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
defaultMonth={selectedDate}
/>
</PopoverContent>
</Popover>
<Input
type="time"
value={time}
onChange={handleTimeChange}
className="w-[120px] text-sm shrink-0 appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
);
}

View file

@ -0,0 +1,86 @@
"use client";
import { TagInput, type Tag as TagType } from "emblor";
import { useCallback, useEffect, useRef, useState } from "react";
function parseEmailsToTags(value: string): TagType[] {
if (!value.trim()) return [];
return value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((email, i) => ({ id: `${Date.now()}-${i}`, text: email }));
}
function tagsToEmailString(tags: TagType[]): string {
return tags.map((t) => t.text).join(", ");
}
/**
* Comma-separated email field rendered as a tag input. Internal tag
* state is the source of truth; comma-string is propagated to the
* caller via ``onChange`` whenever tags change (skipping the initial
* mount to avoid spurious updates).
*/
export function EmailsTagField({
id,
value,
onChange,
placeholder,
}: {
id: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
const [tags, setTags] = useState<TagType[]>(() => parseEmailsToTags(value));
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const isInitialMount = useRef(true);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
onChangeRef.current(tagsToEmailString(tags));
}, [tags]);
const handleSetTags = useCallback((newTags: TagType[] | ((prev: TagType[]) => TagType[])) => {
setTags((prev) => (typeof newTags === "function" ? newTags(prev) : newTags));
}, []);
const handleAddTag = useCallback((text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
setTags((prev) => {
if (prev.some((tag) => tag.text === trimmed)) return prev;
const newTag: TagType = { id: Date.now().toString(), text: trimmed };
return [...prev, newTag];
});
}, []);
return (
<TagInput
id={id}
tags={tags}
setTags={handleSetTags}
placeholder={placeholder ?? "Add email"}
onAddTag={handleAddTag}
styleClasses={{
inlineTagsContainer:
"border border-input rounded-md bg-transparent shadow-xs transition-[color,box-shadow] outline-none focus-within:border-ring p-1 gap-1",
input:
"w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7 text-foreground placeholder:text-muted-foreground bg-transparent text-sm md:text-sm",
tag: {
body: "h-7 relative bg-accent dark:bg-muted/60 border-0 hover:bg-accent/80 dark:hover:bg-muted rounded-md font-medium text-xs text-foreground/80 ps-2 pe-7 flex",
closeButton:
"absolute -inset-y-px -end-px p-0 rounded-e-md flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-foreground hover:text-foreground",
},
}}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
/>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { ExtraField } from "../edit-panel.atom";
import { DateTimePickerField } from "./calendar-field";
import { EmailsTagField } from "./email-tags-field";
/**
* Renders ``ExtraField[]`` as a labelled vertical stack. Picks the
* input control from ``field.type``; unknown types fall back to a
* plain ``<Input type={field.type} />`` (covers "text" and "email").
*
* Pure presentational component owns no state, just maps values to
* controls and propagates changes through ``onFieldChange(key, value)``.
*/
export function ExtraFieldsSection({
fields,
values,
onFieldChange,
}: {
fields: ExtraField[];
values: Record<string, string>;
onFieldChange: (key: string, value: string) => void;
}) {
if (fields.length === 0) return null;
return (
<div className="flex flex-col gap-3 px-4 py-3 border-b">
{fields.map((field) => {
const fieldId = `extra-field-${field.key}`;
const currentValue = values[field.key] ?? "";
return (
<div key={field.key} className="flex flex-col gap-1.5">
<Label htmlFor={fieldId} className="text-xs font-medium text-muted-foreground">
{field.label}
</Label>
{field.type === "emails" ? (
<EmailsTagField
id={fieldId}
value={currentValue}
onChange={(v) => onFieldChange(field.key, v)}
placeholder={`Add ${field.label.toLowerCase()}`}
/>
) : field.type === "datetime-local" ? (
<DateTimePickerField
id={fieldId}
value={currentValue}
onChange={(v) => onFieldChange(field.key, v)}
/>
) : field.type === "textarea" ? (
<Textarea
id={fieldId}
value={currentValue}
onChange={(e) => onFieldChange(field.key, e.target.value)}
className="text-sm min-h-[60px]"
/>
) : (
<Input
id={fieldId}
type={field.type}
value={currentValue}
onChange={(e) => onFieldChange(field.key, e.target.value)}
className="text-sm"
/>
)}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,3 @@
export { DateTimePickerField } from "./calendar-field";
export { EmailsTagField } from "./email-tags-field";
export { ExtraFieldsSection } from "./extra-fields";

View file

@ -0,0 +1,7 @@
export { HitlEditPanel, HitlEditPanelContent, MobileHitlEditPanel } from "./edit-panel";
export type { ExtraField } from "./edit-panel.atom";
export {
closeHitlEditPanelAtom,
hitlEditPanelAtom,
openHitlEditPanelAtom,
} from "./edit-panel.atom";