Files
kandev/apps/web/components/task/chat/queued-ghost-list.tsx
T
2026-08-06 13:45:55 +01:00

545 lines
16 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { IconLayoutList } from "@tabler/icons-react";
import { toast } from "@/lib/toast/sonner";
import { useTranslation } from "react-i18next";
import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import { stripSystemTags } from "@/lib/utils/system-tags";
import {
MergeReferenceOverflowError,
QueueEntryNotFoundError,
QueueSendNowError,
} from "@/lib/api/domains/queue-api";
import { useQueue } from "@/hooks/domains/session/use-queue";
import { canMergeWithAbove, QueuedGhostMessage } from "./queued-ghost-message";
import { QueuePanelHeader } from "./queued-ghost-panel-header";
import type { QueuedMessage } from "@/lib/state/slices/session/types";
import type { EntityReference } from "@/lib/types/entity-reference";
const HEAD_PREVIEW_MAX = 80;
/** Only WebSocket-authored user rows use the editable `user` provenance. */
function canUserEditEntry(entry: QueuedMessage): boolean {
return entry.queued_by === "user";
}
function headPreviewText(entries: QueuedMessage[]): string {
const first = entries[0];
if (!first) return "";
const clean = stripSystemTags(first.content);
if (clean.length <= HEAD_PREVIEW_MAX) return clean;
return clean.slice(0, HEAD_PREVIEW_MAX).trimEnd() + "…";
}
function isEditableTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
// contentEditable covers the TipTap chat editor and any rich-text surface.
// `el.isContentEditable` walks up the contenteditable inheritance chain.
return el.isContentEditable;
}
function useEscToClose(open: boolean, onClose: () => void): void {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
// Don't hijack Esc while the user is editing inside any input control on
// the page (queue textarea, TipTap chat editor, native input/select, or
// the clarification overlay).
if (isEditableTarget(e.target)) return;
e.preventDefault();
e.stopPropagation();
onClose();
};
// Capture Escape before an enclosing Radix dialog handles it as a dismissal.
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [open, onClose]);
}
type QueueAffordanceProps = {
sessionId: string | null;
children: ReactNode;
canDrain?: boolean;
/**
* Optional render slot for placing the chip inside an external row (e.g. the
* chat status bar). The callback receives the chip node (or `null` when no
* chip should be shown) and returns the row markup. When omitted, the chip
* falls back to rendering inline above the input.
*/
renderStatusBar?: (queueChip: ReactNode) => ReactNode;
};
type QueuePanelHandlerArgs = {
clearAll: () => Promise<void>;
drainNext: () => Promise<void>;
editEntry: (
entryId: string,
content: string,
attachments?: undefined,
entityReferences?: EntityReference[],
) => Promise<void>;
removeEntry: (entryId: string) => Promise<void>;
mergeEntry: (entryId: string) => Promise<void>;
sendEntryNow: (entryId: string) => Promise<void>;
sendAllNow: () => Promise<void>;
};
function useSendNowPanelHandlers(
sendEntryNow: (entryId: string) => Promise<void>,
sendAllNow: () => Promise<void>,
) {
const { t } = useTranslation();
const sendNowErrorMessage = useCallback(
(err: unknown) => {
if (err instanceof QueueEntryNotFoundError) return t("chat:sendNowEntryAlreadySent");
if (err instanceof QueueSendNowError) {
const messages: Record<string, string> = {
queue_empty: "chat:sendNowQueueEmpty",
queue_changed: "chat:sendNowQueueChanged",
send_now_conflict: "chat:sendNowConflict",
turn_changed: "chat:sendNowTurnChanged",
send_now_attachment_overflow: "chat:sendNowAttachmentOverflow",
send_now_reference_overflow: "chat:sendNowReferenceOverflow",
};
const key = messages[err.code];
if (key) return t(key);
}
return t("chat:failedToSendQueuedMessageNow");
},
[t],
);
const handleSendEntryNow = useCallback(
(entryId: string) => {
sendEntryNow(entryId).catch((err) => {
console.error("Failed to send queued message now:", err);
toast.error(sendNowErrorMessage(err));
});
},
[sendEntryNow, sendNowErrorMessage],
);
const handleSendAllNow = useCallback(() => {
sendAllNow().catch((err) => {
console.error("Failed to send queued messages now:", err);
toast.error(sendNowErrorMessage(err));
});
}, [sendAllNow, sendNowErrorMessage]);
return { handleSendEntryNow, handleSendAllNow };
}
function useQueuePanelHandlers({
clearAll,
drainNext,
editEntry,
removeEntry,
mergeEntry,
sendEntryNow,
sendAllNow,
}: QueuePanelHandlerArgs) {
// Tracks merge requests still in flight so a rapid second click on the same
// row cannot fire a second request for an entry that is already gone — the
// first click's success refetch removes the row and the second would surface
// a spurious "not found" error.
const pendingMerges = useRef(new Set<string>());
const { t } = useTranslation();
const { handleSendEntryNow, handleSendAllNow } = useSendNowPanelHandlers(
sendEntryNow,
sendAllNow,
);
const handleSave = useCallback(
async (entryId: string, content: string, entityReferences: EntityReference[]) => {
await editEntry(entryId, content, undefined, entityReferences);
},
[editEntry],
);
const handleRemove = useCallback(
async (entryId: string) => {
try {
await removeEntry(entryId);
} catch (err) {
console.error("Failed to remove queued entry:", err);
toast.error(t("chat:failedToRemoveQueuedMessage"));
}
},
[removeEntry, t],
);
const handleMerge = useCallback(
async (entryId: string) => {
if (pendingMerges.current.has(entryId)) return;
pendingMerges.current.add(entryId);
try {
await mergeEntry(entryId);
} catch (err) {
// A drain race (QueueEntryNotFoundError) already triggered a refetch in
// mergeEntry, so the queue view is resynced — no toast needed for the
// benign, self-recovering case.
if (err instanceof QueueEntryNotFoundError) return;
if (err instanceof MergeReferenceOverflowError) {
toast.error(t("chat:mergeReferenceOverflow"));
return;
}
console.error("Failed to merge queued entry:", err);
toast.error(t("chat:failedToMergeQueuedMessages"));
} finally {
pendingMerges.current.delete(entryId);
}
},
[mergeEntry],
);
const handleClear = useCallback(() => {
clearAll().catch((err) => {
console.error("Failed to clear queued messages:", err);
toast.error(t("chat:failedToClearQueuedMessages"));
});
}, [clearAll, t]);
const handleDrain = useCallback(() => {
drainNext().catch((err) => {
console.error("Failed to run queued message:", err);
toast.error(t("chat:failedToRunQueuedMessage"));
});
}, [drainNext, t]);
return {
handleSave,
handleRemove,
handleMerge,
handleClear,
handleDrain,
handleSendEntryNow,
handleSendAllNow,
};
}
type QueuePanelDisclosureProps = {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
entries: QueuedMessage[];
count: number;
max: number;
isFull: boolean;
canDrain: boolean;
isLoading: boolean;
cancellationPending: boolean;
onClose: () => void;
onClear: () => void;
onDrain: () => void;
onSendNow: () => void;
onSave: (entryId: string, content: string, refs: EntityReference[]) => Promise<void>;
onRemove: (entryId: string) => Promise<void>;
onMerge: (entryId: string) => Promise<void>;
onSendEntryNow: (entryId: string) => void;
};
function QueuePanelDisclosure({
isOpen,
onOpenChange,
entries,
count,
max,
isFull,
canDrain,
isLoading,
cancellationPending,
onClose,
onClear,
onDrain,
onSendNow,
onSave,
onRemove,
onMerge,
onSendEntryNow,
}: QueuePanelDisclosureProps) {
return (
<Collapsible open={isOpen} onOpenChange={onOpenChange}>
<CollapsibleContent
className={cn(
"overflow-hidden",
"data-[state=open]:animate-queue-open data-[state=closed]:animate-queue-close",
)}
>
<QueuePanel
entries={entries}
count={count}
max={max}
isFull={isFull}
canDrain={canDrain}
isLoading={isLoading}
cancellationPending={cancellationPending}
onClose={onClose}
onClear={onClear}
onDrain={onDrain}
onSendNow={onSendNow}
onSave={onSave}
onRemove={onRemove}
onMerge={onMerge}
onSendEntryNow={onSendEntryNow}
/>
</CollapsibleContent>
</Collapsible>
);
}
function useQueuePanelOpenState(sessionId: string | null, entryCount: number) {
const [isOpen, setIsOpen] = useState(false);
const [lastSession, setLastSession] = useState(sessionId);
const [lastEntryCount, setLastEntryCount] = useState(entryCount);
if (sessionId !== lastSession) {
setLastSession(sessionId);
setIsOpen(false);
}
if (entryCount !== lastEntryCount) {
setLastEntryCount(entryCount);
if (entryCount === 0) setIsOpen(false);
}
return [isOpen, setIsOpen] as const;
}
/**
* Wraps the chat input with the per-session queue affordance:
* - When there are no queued entries, just renders `children` (the input).
* - Otherwise a small "n queued" chip is exposed (either inline above the
* input, or via `renderStatusBar` into a caller-controlled row); clicking
* it expands a panel above the input. Drained or session-switched queues
* auto-collapse.
*/
export function QueueAffordance({
sessionId,
children,
canDrain = false,
renderStatusBar,
}: QueueAffordanceProps) {
const {
entries,
count,
max,
isFull,
isLoading,
clearAll,
drainNext,
editEntry,
removeEntry,
mergeEntry,
sendEntryNow,
sendAllNow,
cancellationPending,
} = useQueue(sessionId);
const entryCount = entries.length;
const [isOpen, setIsOpen] = useQueuePanelOpenState(sessionId, entryCount);
const {
handleSave,
handleRemove,
handleMerge,
handleClear,
handleDrain,
handleSendEntryNow,
handleSendAllNow,
} = useQueuePanelHandlers({
clearAll,
drainNext,
editEntry,
removeEntry,
mergeEntry,
sendEntryNow,
sendAllNow,
});
// Reset disclosure on session switch or full drain using render-phase state
// adjustment (React docs: "Adjusting some state when a prop changes"). This
// avoids the cascading-render anti-pattern of doing it inside useEffect.
const close = useCallback(() => setIsOpen(false), []);
useEscToClose(isOpen, close);
const hasEntries = !!sessionId && entryCount > 0;
const chipNode =
hasEntries && !isOpen ? (
<QueueChip
count={count}
isFull={isFull}
previewText={headPreviewText(entries)}
onToggle={() => setIsOpen((v) => !v)}
/>
) : null;
if (!hasEntries) {
return (
<>
{/* Call renderStatusBar even with null so the status bar stays mounted when the queue is empty. */}
{renderStatusBar?.(null)}
{children}
</>
);
}
return (
<>
{renderStatusBar?.(chipNode)}
<QueuePanelDisclosure
isOpen={isOpen}
onOpenChange={setIsOpen}
entries={entries}
count={count}
max={max}
isFull={isFull}
canDrain={canDrain}
isLoading={isLoading}
cancellationPending={cancellationPending}
onClose={close}
onClear={handleClear}
onDrain={handleDrain}
onSendNow={handleSendAllNow}
onSave={handleSave}
onRemove={handleRemove}
onMerge={handleMerge}
onSendEntryNow={handleSendEntryNow}
/>
{!renderStatusBar && chipNode && (
<div className="flex items-center px-1 pb-1 animate-in fade-in-0 slide-in-from-bottom-1 duration-150 motion-reduce:animate-none">
{chipNode}
</div>
)}
{children}
</>
);
}
type QueueChipProps = {
count: number;
isFull: boolean;
previewText: string;
onToggle: () => void;
};
function chipPalette(isFull: boolean): string {
if (isFull) {
return "text-amber-600 dark:text-amber-400 border-amber-500/60 hover:bg-amber-500/10";
}
return "text-muted-foreground border-muted-foreground/40 hover:text-foreground hover:border-muted-foreground/60";
}
function QueueChip({ count, isFull, previewText, onToggle }: QueueChipProps) {
const { t } = useTranslation();
// aria-expanded/aria-controls are intentionally omitted: the chip and the
// expanded panel are mutually exclusive in the DOM (clicking the chip swaps
// it for the panel header, which carries its own collapse controls). Pointing
// aria-controls at an element that isn't currently mounted would be a worse
// a11y signal than the descriptive aria-label below.
const button = (
<button
type="button"
data-testid="queue-chip"
data-full={isFull ? "true" : "false"}
aria-label={t("chat:queueChipExpand", { count })}
onClick={onToggle}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5",
"text-[11px] font-medium cursor-pointer transition-colors",
"[@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:px-3",
chipPalette(isFull),
)}
>
<IconLayoutList className="h-3 w-3" />
<span>{t("chat:queuedCount", { count })}</span>
{isFull && <span className="opacity-80">{t("chat:queueFullSuffix")}</span>}
</button>
);
if (!previewText) return button;
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-[280px]">
<span className="line-clamp-2">{previewText}</span>
</TooltipContent>
</Tooltip>
);
}
type QueuePanelProps = {
entries: QueuedMessage[];
count: number;
max: number;
isFull: boolean;
canDrain: boolean;
isLoading: boolean;
cancellationPending: boolean;
onClose: () => void;
onClear: () => void;
onDrain: () => void;
onSendNow: () => void;
onSave: (entryId: string, content: string, entityReferences: EntityReference[]) => Promise<void>;
onRemove: (entryId: string) => Promise<void>;
onMerge: (entryId: string) => Promise<void>;
onSendEntryNow: (entryId: string) => void;
};
function QueuePanel({
entries,
count,
max,
isFull,
canDrain,
isLoading,
cancellationPending,
onClose,
onClear,
onDrain,
onSendNow,
onSave,
onRemove,
onMerge,
onSendEntryNow,
}: QueuePanelProps) {
const { t } = useTranslation();
return (
<div
id="queue-panel"
role="region"
aria-label={t("chat:queuedMessages")}
data-testid="queued-ghost-list"
className={cn(
"flex max-h-[min(40dvh,32rem)] flex-shrink-0 flex-col px-3 pt-1.5 pb-1",
"border-t border-border/40",
"animate-in slide-in-from-bottom-2 fade-in-0 duration-200",
)}
>
<QueuePanelHeader
count={count}
max={max}
isFull={isFull}
canDrain={canDrain}
isLoading={isLoading}
cancellationPending={cancellationPending}
onClear={onClear}
onDrain={onDrain}
onSendNow={onSendNow}
onClose={onClose}
/>
<div
data-testid="queue-scroll-region"
className="min-h-0 flex-1 space-y-1.5 overflow-y-auto overscroll-contain pr-1"
>
{entries.map((entry, index) => (
<QueuedGhostMessage
key={entry.id}
entry={entry}
index={index}
canEdit={canUserEditEntry(entry)}
canRemove
canMerge={canMergeWithAbove(entry, entries[index - 1])}
onSave={(content, entityReferences) => onSave(entry.id, content, entityReferences)}
onRemove={() => onRemove(entry.id)}
onMerge={() => onMerge(entry.id)}
onSendNow={() => onSendEntryNow(entry.id)}
sendNowDisabled={isLoading || cancellationPending}
/>
))}
</div>
</div>
);
}