Files
kandev/apps/web/components/task/chat/queued-ghost-message.tsx
T
2026-09-22 23:36:47 +01:00

686 lines
23 KiB
TypeScript

"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import type { ReactNode } from "react";
import { IconInfoCircle, IconRobot, IconUser } from "@tabler/icons-react";
import ReactMarkdown from "react-markdown";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core";
import { toast } from "@/lib/toast/sonner";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { QueueEntryNotFoundError } from "@/lib/api/domains/queue-api";
import { stripSystemTags } from "@/lib/utils/system-tags";
import {
SenderTaskBadge,
type SenderTaskInfo,
} from "@/components/task/chat/messages/sender-task-badge";
import {
WorkflowStepMessageBadge,
workflowMessageInfoFromMetadata,
type WorkflowStepMessageInfo,
} from "@/components/task/chat/messages/workflow-step-message-badge";
import { rehypePlugins, remarkPlugins } from "@/components/shared/markdown-components";
import type { QueuedMessage } from "@/lib/state/slices/session/types";
import type { EntityReference } from "@/lib/types/entity-reference";
import {
entityReferencesFromMetadata,
survivingEntityReferences,
} from "@/lib/entity-references/message-references";
import { buildEntityReferenceMarkdownComponents } from "@/components/task/chat/messages/entity-reference-chip";
import { BoundedMessagePreview } from "@/components/task/chat/messages/bounded-message-preview";
import { QueuedGhostRowActions } from "@/components/task/chat/queued-ghost-row-actions";
import { useQueuedMessageOverflow } from "@/components/task/chat/use-queued-message-overflow";
import { AttachmentRow, type QueuedAttachment } from "@/components/task/chat/queued-attachment-row";
import { QueuedGhostEditView } from "@/components/task/chat/queued-ghost-edit-view";
import { t } from "@/lib/i18n";
import { useClarificationEscapeGuard } from "@/hooks/use-clarification-escape-guard";
import {
useQueuedGhostLeaseLoss,
useQueuedGhostStartEdit,
} from "@/hooks/use-queue-edit-protection";
/** Imperative handle for the ghost row, used by chat input "edit last queued" affordance. */
export type QueuedGhostMessageHandle = {
startEdit: () => void;
};
type SenderKind = "user" | "agent" | "workflow" | "system";
export function isWorkflowQueuedMessage(entry: QueuedMessage): boolean {
return (
entry.queued_by === "workflow" ||
entry.queued_by === "workflow-auto-start" ||
workflowMessageInfoFromMetadata(entry.metadata) !== null
);
}
function senderKindOf(entry: QueuedMessage): SenderKind {
if (isWorkflowQueuedMessage(entry)) return "workflow";
if (!entry.queued_by) return "system";
// Inter-task messages dispatched via dispatchTaskMessage hardcode
// queued_by="agent"; that's the only signal needed.
if (entry.queued_by === "agent") return "agent";
return "user";
}
/** A message may serve as a merge source or target only when its sender kind is
* "user" or "agent". Server-owned rows (queued_by="server") are reserved
* backend rows and are never mergeable. */
export function canMergeEntry(entry: QueuedMessage): boolean {
if (entry.queued_by === "server") return false;
if (
entry.metadata &&
("plan_comment_refs" in entry.metadata ||
"plan_comment_request_fingerprint" in entry.metadata ||
"client_queue_id" in entry.metadata)
) {
return false;
}
const kind = senderKindOf(entry);
return kind === "user" || kind === "agent";
}
/** Two messages may merge when both are mergeable and share the same sender
* kind. Agent entries additionally require an identical non-empty
* sender_task_id; user entries require both rows to be owned by the caller
* identity (callerId defaults to the backend's QueuedByUser, which is what the
* SPA sends when it omits user_id). Mirrors the backend's mergeAllowed
* predicate so the button never appears for merges the server would reject. */
export function canMergeWithAbove(
entry: QueuedMessage,
above: QueuedMessage | undefined,
callerId = "user",
): boolean {
if (!above) return false;
if (!canMergeEntry(entry) || !canMergeEntry(above)) return false;
if (senderKindOf(entry) !== senderKindOf(above)) return false;
if (senderKindOf(entry) === "agent") {
const senderTaskId = getSenderTaskInfo(entry)?.id;
return senderTaskId !== undefined && senderTaskId === getSenderTaskInfo(above)?.id;
}
if (senderKindOf(entry) === "user") {
return callerId !== "" && entry.queued_by === callerId && above.queued_by === callerId;
}
return false;
}
function senderLabel(entry: QueuedMessage): string {
const kind = senderKindOf(entry);
if (kind === "agent") {
const title = entry.metadata?.sender_task_title;
const sessionName = entry.metadata?.sender_session_name;
const base =
typeof title === "string" && title.length > 0
? t("task:fromTask", { title })
: t("task:fromAgent");
return typeof sessionName === "string" && sessionName.length > 0
? t("task:senderWithSession", { base, sessionName })
: base;
}
if (kind === "workflow") return t("task:workflowSender");
if (kind === "system") return t("task:systemSender");
return t("task:you");
}
type SenderIconProps = { entry: QueuedMessage };
function senderIconFor(kind: SenderKind): { Icon: typeof IconUser; tone: string } {
if (kind === "agent") {
return { Icon: IconRobot, tone: "text-amber-500 dark:text-amber-400" };
}
if (kind === "system") {
return { Icon: IconInfoCircle, tone: "text-muted-foreground" };
}
if (kind === "workflow") {
return { Icon: IconInfoCircle, tone: "text-muted-foreground" };
}
return { Icon: IconUser, tone: "text-blue-500 dark:text-blue-300" };
}
function SenderIcon({ entry }: SenderIconProps) {
const { Icon, tone } = senderIconFor(senderKindOf(entry));
return <Icon className={cn("h-3.5 w-3.5 flex-shrink-0", tone)} aria-label={senderLabel(entry)} />;
}
/**
* Returns the inter-task sender info when this entry was queued by another
* task's agent (queued_by="agent" + sender_task_id in metadata). User-typed
* entries have no SenderTaskInfo — they render with just the IconUser cue.
*/
function getSenderTaskInfo(entry: QueuedMessage): SenderTaskInfo | null {
if (senderKindOf(entry) !== "agent") return null;
const meta = entry.metadata as
| {
sender_task_id?: string;
sender_task_title?: string;
sender_session_id?: string;
sender_session_name?: string;
}
| undefined;
const id = meta?.sender_task_id;
if (typeof id !== "string" || id.length === 0) return null;
return {
id,
snapshotTitle: meta?.sender_task_title ?? "",
sessionId: meta?.sender_session_id,
sessionName: meta?.sender_session_name,
};
}
function getWorkflowMessageInfo(entry: QueuedMessage): WorkflowStepMessageInfo | null {
const info = workflowMessageInfoFromMetadata(entry.metadata);
if (info) return info;
return entry.queued_by === "workflow" || entry.queued_by === "workflow-auto-start" ? {} : null;
}
type DisplayViewProps = {
entry: QueuedMessage;
entityReferences: readonly EntityReference[];
positionLabel: string;
canEdit: boolean;
canRemove: boolean;
canMerge: boolean;
onStartEdit: () => void;
onRemove: () => void;
onMerge?: () => void | Promise<void>;
onSendNow: () => void;
sendNowDisabled: boolean;
};
function queuePositionLabel(index: number | undefined, position: number | undefined): string {
return `#${index === undefined ? (position ?? 1) : index + 1}`;
}
function DisplayView({
entry,
entityReferences,
positionLabel,
canEdit,
canRemove,
canMerge,
onStartEdit,
onRemove,
onMerge,
onSendNow,
sendNowDisabled,
}: DisplayViewProps) {
const { t } = useTranslation();
const visible = stripSystemTags(entry.content);
const attachments = (entry.attachments ?? []) as QueuedAttachment[];
const senderTask = getSenderTaskInfo(entry);
const workflowMessage = getWorkflowMessageInfo(entry);
const [expanded, setExpanded] = useState(false);
const { previewRef, disclosureButtonRef, canExpand } = useQueuedMessageOverflow(
visible,
expanded,
setExpanded,
);
const referenceMarkdownComponents = useMemo(
() => buildEntityReferenceMarkdownComponents(entityReferences),
[entityReferences],
);
return (
<div className="group flex items-center gap-2 py-1.5" data-testid="queue-entry">
{/* Position label + sender icon are non-interactive; relative keeps them
* painted above the coarse-pointer touch target, and pointer-events-none
* lets touches reach the handle behind them. */}
<span className="relative flex items-center gap-1.5 text-muted-foreground pointer-events-none">
<span
aria-label={t("task:position", { positionLabel })}
className="font-mono text-[10px] tabular-nums"
>
{positionLabel}
</span>
{!senderTask && !workflowMessage && <SenderIcon entry={entry} />}
</span>
<div className="flex-1 min-w-0 space-y-1">
{workflowMessage && <WorkflowStepMessageBadge workflow={workflowMessage} size="xs" />}
{senderTask && <SenderTaskBadge sender={senderTask} size="xs" />}
{visible && (
<BoundedMessagePreview
source={visible}
fileName="kandev-queued-message.txt"
previewRef={previewRef}
previewTestId="queue-entry-text"
previewDataExpanded={expanded}
previewClassName={cn(
"markdown-body max-w-none text-sm text-foreground/80 break-words overflow-hidden",
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
expanded ? "max-h-[40rem]" : "max-h-[2.75rem]",
)}
renderContent={(preview) => (
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={referenceMarkdownComponents}
>
{preview}
</ReactMarkdown>
)}
/>
)}
<AttachmentRow attachments={attachments} interactive={true} />
</div>
<QueuedGhostRowActions
canExpand={canExpand}
disclosureButtonRef={disclosureButtonRef}
expanded={expanded}
canMerge={canMerge}
canEdit={canEdit}
canRemove={canRemove}
onToggleExpand={() => setExpanded((v) => !v)}
onMerge={onMerge}
onStartEdit={onStartEdit}
onRemove={onRemove}
onSendNow={onSendNow}
sendNowDisabled={sendNowDisabled}
/>
</div>
);
}
type QueueGrabHandleProps = {
canDrag: boolean;
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners;
/** dnd-kit activator node ref: after a keyboard drag, focus returns here. */
setActivatorNodeRef: (node: HTMLElement | null) => void;
};
/**
* Dotted drag handle that floats over the row's left edge. It is absolutely
* positioned so nothing in the row shifts to make room. It is always visible
* when the queue has multiple entries, attached flush to the box's left edge
* and painted behind the row content on coarse pointers (no chip surface —
* the dots read as part of the box edge).
* Dragging starts only from this handle.
*/
function QueueGrabHandle({
canDrag,
attributes,
listeners,
setActivatorNodeRef,
}: QueueGrabHandleProps) {
const { t } = useTranslation();
const dragProps = canDrag ? { ...attributes, ...listeners } : {};
return (
<button
type="button"
ref={setActivatorNodeRef}
disabled={!canDrag}
aria-label={t("chat:reorderQueuedMessage")}
data-testid="queue-grab-handle"
{...dragProps}
aria-roledescription={t("chat:sortable")}
className={cn(
"absolute top-1/2 z-10 -translate-y-1/2 touch-none rounded-md",
"flex items-center justify-center",
"left-1 h-3 w-3",
"text-muted-foreground/70 hover:text-muted-foreground",
"border-0 bg-transparent shadow-none",
"opacity-100 transition-opacity duration-150",
canDrag ? "cursor-grab active:cursor-grabbing" : "cursor-not-allowed opacity-30",
// Coarse pointers: keep a 44px touch target while the visible grip
// remains small and behind the row content.
"[@media(pointer:coarse)]:opacity-100",
"[@media(pointer:coarse)]:left-0 [@media(pointer:coarse)]:z-0",
"[@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11",
"[@media(pointer:coarse)]:border-0",
"[@media(pointer:coarse)]:bg-transparent",
"[@media(pointer:coarse)]:shadow-none",
"[@media(pointer:coarse)]:justify-start [@media(pointer:coarse)]:pl-2",
)}
>
<span aria-hidden className="grid grid-cols-2 gap-0.5">
{[0, 1, 2, 3, 4, 5].map((dot) => (
<span key={dot} className="h-px w-px rounded-full bg-current" />
))}
</span>
</button>
);
}
type SortableRowShellProps = {
id: string;
/** useSortable is disabled while the row is edited or reordering is off. */
disabled: boolean;
canDrag: boolean;
/** The grab handle is hidden while the row is being edited. */
showHandle: boolean;
isDragging: boolean;
children: ReactNode;
};
/** Sortable row wrapper: owns the dnd-kit node ref, transform, and the dotted
* grab handle overlay, keeping QueuedGhostMessage focused on content. */
function SortableRowShell({
id,
disabled,
canDrag,
showHandle,
isDragging,
children,
}: SortableRowShellProps) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition } =
useSortable({
id,
disabled,
});
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
"group relative rounded-md border border-border/60 bg-background/40 pr-2 text-sm",
"hover:border-border transition-colors",
showHandle ? "pl-5 [@media(pointer:coarse)]:pl-11" : "pl-2",
isDragging && "z-10 opacity-40",
)}
>
{showHandle && (
<QueueGrabHandle
canDrag={canDrag}
attributes={attributes}
listeners={listeners}
setActivatorNodeRef={setActivatorNodeRef}
/>
)}
{children}
</div>
);
}
type QueuedGhostMessageProps = {
entry: QueuedMessage;
/** Zero-based render position; combined with entry.position to label the row. */
index?: number;
/**
* Edit is only allowed when the caller's identity (currentUserId) matches the
* entry's queued_by. Inter-task entries are visible but read-only.
*/
canEdit: boolean;
/**
* Becomes false when the server-side edit lease is lost, so a row cannot
* continue presenting an editor that can no longer submit.
*/
editLeaseActive?: boolean;
/** Removal is independent from edit ownership and applies to every visible row. */
canRemove?: boolean;
/**
* Merge-with-above control; only shown when this row and the one above it
* share a sender kind. Optional so standalone renders (tests) can omit it.
*/
canMerge?: boolean;
/**
* Reordering is enabled unless a queue mutation or backend cancellation is
* in flight; the row's own edit view also suppresses the handle. The queue
* panel hides it when there is only one entry. Optional so standalone renders
* default to showing the handle.
*/
canDrag?: boolean;
/** Whether this row belongs to a queue with enough entries to reorder. */
showDragHandle?: boolean;
/** Set while this row is the active drag target, for dimming/z-index. */
isDragging?: boolean;
onSave: (
content: string,
entityReferences: EntityReference[],
attachments?: QueuedMessage["attachments"],
) => Promise<void>;
onRemove: () => void | Promise<void>;
/** Fold this entry into the one above it. */
onMerge?: () => void | Promise<void>;
/** Interrupt the current turn and dispatch this exact queued entry. */
onSendNow?: () => void;
/** Disable while the queue mutation or backend cancellation is in flight. */
sendNowDisabled?: boolean;
/** Called before edit activation; returning false keeps the row read-only. */
onEditStart?: () => void | Promise<boolean | string | void>;
/** Called after edit save/cancel so the parent can restore queue policy. */
onEditComplete?: (editToken?: string, saved?: boolean) => void | Promise<void>;
};
function useFocusQueuedEdit(
editing: boolean,
textareaRef: React.RefObject<HTMLTextAreaElement | null>,
): void {
useQueuedGhostEditEscapeGuard(editing, textareaRef);
useEffect(() => {
if (!editing || !textareaRef.current) return;
const textarea = textareaRef.current;
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
}, [editing, textareaRef]);
}
function useQueuedGhostEditEscapeGuard(
editing: boolean,
textareaRef: React.RefObject<HTMLTextAreaElement | null>,
): void {
const editEscapeGuard = useCallback(
(event: KeyboardEvent) => {
if (!editing || event.key !== "Escape") return false;
const textarea = textareaRef.current;
if (!textarea) return false;
const target = event.target;
return (
target === textarea ||
(target instanceof Node && textarea.contains(target)) ||
document.activeElement === textarea
);
},
[editing, textareaRef],
);
useClarificationEscapeGuard(editing ? editEscapeGuard : null);
}
type QueuedGhostSaveArgs = {
value: string;
entryContent: string;
entityReferences: readonly EntityReference[];
attachments?: QueuedMessage["attachments"];
onSave: QueuedGhostMessageProps["onSave"];
onEditComplete?: (editToken?: string, saved?: boolean) => void | Promise<void>;
editTokenRef: React.RefObject<string | undefined>;
setEditing: (editing: boolean) => void;
setSaving: (saving: boolean) => void;
t: (key: string) => string;
};
function useQueuedGhostSave({
value,
entryContent,
entityReferences,
attachments,
onSave,
onEditComplete,
editTokenRef,
setEditing,
setSaving,
t,
}: QueuedGhostSaveArgs) {
return useCallback(async () => {
const editToken = editTokenRef.current;
const trimmed = value.trim();
const hasAttachments = (attachments?.length ?? 0) > 0;
if ((!trimmed && !hasAttachments) || trimmed === entryContent) {
setEditing(false);
await onEditComplete?.(editToken, false);
return;
}
setSaving(true);
try {
const updatedReferences = survivingEntityReferences(trimmed, entityReferences);
if (attachments === undefined) {
await onSave(trimmed, updatedReferences);
} else {
await onSave(trimmed, updatedReferences, attachments);
}
setEditing(false);
await onEditComplete?.(editToken, true);
} catch (err) {
console.error("Failed to update queued entry:", err);
if (err instanceof QueueEntryNotFoundError) {
toast.error(t("chat:queueEditAlreadySent"));
} else {
toast.error(t("chat:queueEditSaveFailed"));
}
setEditing(false);
await onEditComplete?.(editToken, false);
} finally {
setSaving(false);
}
}, [
value,
entryContent,
onSave,
onEditComplete,
entityReferences,
attachments,
editTokenRef,
setEditing,
setSaving,
t,
]);
}
type QueuedGhostCancelArgs = {
entryContent: string;
onEditComplete?: (editToken?: string, saved?: boolean) => void | Promise<void>;
editTokenRef: React.RefObject<string | undefined>;
setValue: (value: string) => void;
setEditing: (editing: boolean) => void;
};
function useQueuedGhostCancel({
entryContent,
onEditComplete,
editTokenRef,
setValue,
setEditing,
}: QueuedGhostCancelArgs) {
return useCallback(async () => {
const editToken = editTokenRef.current;
setValue(entryContent);
setEditing(false);
await onEditComplete?.(editToken);
}, [editTokenRef, entryContent, onEditComplete, setEditing, setValue]);
}
function useQueuedGhostMetadata(entry: QueuedMessage, canMerge: boolean) {
const entityReferences = useMemo(
() => entityReferencesFromMetadata(entry.metadata),
[entry.metadata],
);
return { entityReferences, effectiveCanMerge: canMerge && canMergeEntry(entry) };
}
export const QueuedGhostMessage = forwardRef<QueuedGhostMessageHandle, QueuedGhostMessageProps>(
function QueuedGhostMessage(
{
entry,
index,
canEdit,
editLeaseActive = true,
canRemove = true,
canMerge = false,
canDrag = true,
showDragHandle = true,
isDragging = false,
onSave,
onRemove,
onMerge,
onSendNow = () => undefined,
sendNowDisabled = false,
onEditStart,
onEditComplete,
},
ref,
) {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(entry.content);
const [saving, setSaving] = useState(false);
const editTokenRef = useRef<string | undefined>(undefined);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { entityReferences, effectiveCanMerge } = useQueuedGhostMetadata(entry, canMerge);
useFocusQueuedEdit(editing, textareaRef);
useQueuedGhostLeaseLoss(editing, editLeaseActive, entry.content, setValue, setEditing);
const startEdit = useQueuedGhostStartEdit({
canEdit,
editing,
saving,
onEditStart,
onStart: (editToken) => {
editTokenRef.current = editToken;
setValue(entry.content);
setEditing(true);
},
});
useImperativeHandle(ref, () => ({ startEdit }), [startEdit]);
const handleCancel = useQueuedGhostCancel({
entryContent: entry.content,
onEditComplete,
editTokenRef,
setValue,
setEditing,
});
const handleSave = useQueuedGhostSave({
value,
entryContent: entry.content,
entityReferences,
attachments: entry.attachments,
onSave,
onEditComplete,
editTokenRef,
setEditing,
setSaving,
t,
});
return (
<SortableRowShell
id={entry.id}
disabled={editing || !canDrag}
canDrag={canDrag}
showHandle={showDragHandle && !editing}
isDragging={isDragging}
>
{editing ? (
<QueuedGhostEditView
value={value}
saving={saving}
attachments={(entry.attachments ?? []) as QueuedAttachment[]}
onChange={setValue}
onSave={handleSave}
onCancel={handleCancel}
textareaRef={textareaRef}
/>
) : (
<DisplayView
entry={entry}
entityReferences={entityReferences}
positionLabel={queuePositionLabel(index, entry.position)}
canEdit={canEdit}
canRemove={canRemove}
canMerge={effectiveCanMerge}
onStartEdit={startEdit}
onRemove={onRemove}
onMerge={onMerge}
onSendNow={onSendNow}
sendNowDisabled={sendNowDisabled}
/>
)}
</SortableRowShell>
);
},
);