446 lines
15 KiB
TypeScript
446 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
import {
|
|
IconCheck,
|
|
IconChevronDown,
|
|
IconChevronUp,
|
|
IconEdit,
|
|
IconFile,
|
|
IconInfoCircle,
|
|
IconRobot,
|
|
IconUser,
|
|
IconX,
|
|
} from "@tabler/icons-react";
|
|
import ReactMarkdown from "react-markdown";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@kandev/ui";
|
|
import { Textarea } from "@kandev/ui/textarea";
|
|
import { cn } from "@/lib/utils";
|
|
import { QueueEntryNotFoundError } from "@/lib/api/domains/queue-api";
|
|
import { stripSystemTags } from "@/lib/utils/system-tags";
|
|
import { ImagePreviewDialog } from "@/components/task/chat/image-preview-dialog";
|
|
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 { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
|
|
import type { QueuedMessage } from "@/lib/state/slices/session/types";
|
|
|
|
type QueuedAttachment = NonNullable<QueuedMessage["attachments"]>[number];
|
|
|
|
type AttachmentRowProps = {
|
|
attachments: QueuedAttachment[];
|
|
interactive: boolean;
|
|
};
|
|
|
|
/**
|
|
* Renders queued-message attachments as compact thumbnails (images) and chips
|
|
* (other resources). Used in both display and edit views; `interactive=false`
|
|
* disables the click-to-open behavior so it stays a passive context cue while
|
|
* editing the message text.
|
|
*/
|
|
function AttachmentRow({ attachments, interactive }: AttachmentRowProps) {
|
|
if (attachments.length === 0) return null;
|
|
const images = attachments.filter((a) => a.type === "image");
|
|
const files = attachments.filter((a) => a.type !== "image");
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{images.map((att, i) => (
|
|
<ImagePreviewDialog
|
|
key={`img-${i}`}
|
|
src={`data:${att.mime_type};base64,${att.data}`}
|
|
alt={`Attachment ${i + 1}`}
|
|
interactive={interactive}
|
|
thumbnailClassName={cn(
|
|
"h-10 w-10 rounded-md border border-border object-cover",
|
|
interactive && "transition-opacity hover:opacity-90",
|
|
)}
|
|
/>
|
|
))}
|
|
{files.map((_, i) => (
|
|
<span
|
|
key={`file-${i}`}
|
|
className="inline-flex items-center gap-1.5 rounded-full bg-muted/60 px-2 py-0.5 text-xs text-muted-foreground"
|
|
>
|
|
<IconFile className="h-3 w-3" />
|
|
Attachment
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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";
|
|
}
|
|
|
|
function senderLabel(entry: QueuedMessage): string {
|
|
const kind = senderKindOf(entry);
|
|
if (kind === "agent") {
|
|
const title = entry.metadata?.sender_task_title;
|
|
return typeof title === "string" && title.length > 0 ? `From ${title}` : "From agent";
|
|
}
|
|
if (kind === "workflow") return "Workflow";
|
|
if (kind === "system") return "System";
|
|
return "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 }
|
|
| undefined;
|
|
const id = meta?.sender_task_id;
|
|
if (typeof id !== "string" || id.length === 0) return null;
|
|
return { id, snapshotTitle: meta?.sender_task_title ?? "" };
|
|
}
|
|
|
|
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 EditViewProps = {
|
|
value: string;
|
|
saving: boolean;
|
|
attachments: QueuedAttachment[];
|
|
onChange: (v: string) => void;
|
|
onSave: () => void;
|
|
onCancel: () => void;
|
|
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
|
};
|
|
|
|
function EditView({
|
|
value,
|
|
saving,
|
|
attachments,
|
|
onChange,
|
|
onSave,
|
|
onCancel,
|
|
textareaRef,
|
|
}: EditViewProps) {
|
|
const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
onCancel();
|
|
} else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
event.preventDefault();
|
|
onSave();
|
|
}
|
|
};
|
|
return (
|
|
<div className="space-y-2 py-2">
|
|
<AttachmentRow attachments={attachments} interactive={false} />
|
|
<Textarea
|
|
ref={textareaRef}
|
|
data-testid="queue-edit-textarea"
|
|
value={value}
|
|
disabled={saving}
|
|
placeholder="Enter message content..."
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
className={cn(
|
|
"min-h-[60px] max-h-[200px] resize-none overflow-y-auto bg-background border-border",
|
|
)}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
onClick={onSave}
|
|
disabled={saving || !value.trim()}
|
|
className="h-7 cursor-pointer"
|
|
>
|
|
<IconCheck className="mr-1 h-3.5 w-3.5" />
|
|
Save
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={onCancel}
|
|
disabled={saving}
|
|
className="h-7 cursor-pointer"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
Press Esc to cancel, Cmd+Enter to save
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type DisplayViewProps = {
|
|
entry: QueuedMessage;
|
|
positionLabel: string;
|
|
canEdit: boolean;
|
|
onStartEdit: () => void;
|
|
onRemove: () => void;
|
|
};
|
|
|
|
/** Rough threshold above which we offer a per-row expand toggle. Two lines of
|
|
* the row's text width fit ~80 chars OR any explicit newline implies overflow,
|
|
* so we use either signal to surface the chevron — short multi-line messages
|
|
* (lists, code blocks) would otherwise be silently truncated. */
|
|
const EXPAND_THRESHOLD = 80;
|
|
|
|
function shouldOfferExpand(text: string): boolean {
|
|
return text.length > EXPAND_THRESHOLD || text.includes("\n");
|
|
}
|
|
|
|
function DisplayView({ entry, positionLabel, canEdit, onStartEdit, onRemove }: DisplayViewProps) {
|
|
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 canExpand = shouldOfferExpand(visible);
|
|
return (
|
|
<div className="group flex items-start gap-2 py-1.5">
|
|
<span className="flex items-center gap-1.5 mt-0.5 text-muted-foreground">
|
|
<span
|
|
aria-label={`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 && (
|
|
<div
|
|
data-testid="queue-entry-text"
|
|
data-expanded={expanded ? "true" : "false"}
|
|
className={cn(
|
|
"markdown-body max-w-none text-sm text-foreground/80 break-words overflow-hidden",
|
|
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
|
"transition-[max-height] duration-200 ease-out motion-reduce:transition-none",
|
|
expanded ? "max-h-[40rem]" : "max-h-[2.75rem]",
|
|
)}
|
|
>
|
|
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
|
|
{visible}
|
|
</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
<AttachmentRow attachments={attachments} interactive={true} />
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-0.5 flex-shrink-0 transition-opacity",
|
|
// Hover-reveal on devices that support hover (desktop); always
|
|
// visible on touch surfaces where there's no hover affordance.
|
|
"opacity-0 group-hover:opacity-100 focus-within:opacity-100",
|
|
"[@media(hover:none)]:opacity-100",
|
|
)}
|
|
>
|
|
{canExpand && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 cursor-pointer p-0 text-muted-foreground hover:text-foreground"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
title={expanded ? "Collapse message" : "Expand message"}
|
|
data-testid="queue-entry-expand"
|
|
aria-expanded={expanded}
|
|
>
|
|
{expanded ? (
|
|
<IconChevronUp className="h-3.5 w-3.5" />
|
|
) : (
|
|
<IconChevronDown className="h-3.5 w-3.5" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
{canEdit && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 cursor-pointer p-0 text-muted-foreground hover:text-foreground"
|
|
onClick={onStartEdit}
|
|
title="Edit queued message"
|
|
>
|
|
<IconEdit className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 cursor-pointer p-0 text-muted-foreground hover:text-foreground"
|
|
onClick={onRemove}
|
|
title="Remove queued message"
|
|
>
|
|
<IconX className="h-4 w-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</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;
|
|
onSave: (content: string) => Promise<void>;
|
|
onRemove: () => void | Promise<void>;
|
|
/** Called after edit save/cancel so the parent can refocus the chat input. */
|
|
onEditComplete?: () => void;
|
|
};
|
|
|
|
export const QueuedGhostMessage = forwardRef<QueuedGhostMessageHandle, QueuedGhostMessageProps>(
|
|
function QueuedGhostMessage({ entry, index, canEdit, onSave, onRemove, onEditComplete }, ref) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [value, setValue] = useState(entry.content);
|
|
const [saving, setSaving] = useState(false);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!editing) setValue(entry.content);
|
|
}, [entry.content, editing]);
|
|
|
|
useEffect(() => {
|
|
if (editing && textareaRef.current) {
|
|
const el = textareaRef.current;
|
|
el.focus();
|
|
el.setSelectionRange(el.value.length, el.value.length);
|
|
}
|
|
}, [editing]);
|
|
|
|
const startEdit = useCallback(() => {
|
|
if (!canEdit) return;
|
|
setValue(entry.content);
|
|
setEditing(true);
|
|
}, [entry.content, canEdit]);
|
|
|
|
useImperativeHandle(ref, () => ({ startEdit }), [startEdit]);
|
|
|
|
const handleCancel = useCallback(() => {
|
|
setValue(entry.content);
|
|
setEditing(false);
|
|
onEditComplete?.();
|
|
}, [entry.content, onEditComplete]);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
const trimmed = value.trim();
|
|
if (!trimmed || trimmed === entry.content) {
|
|
setEditing(false);
|
|
onEditComplete?.();
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
await onSave(trimmed);
|
|
setEditing(false);
|
|
onEditComplete?.();
|
|
} catch (err) {
|
|
console.error("Failed to update queued entry:", err);
|
|
// Exit edit mode so the user sees the current state instead of being
|
|
// stuck in a textarea with no signal that the save failed (drain race
|
|
// or transient network error).
|
|
if (err instanceof QueueEntryNotFoundError) {
|
|
toast.error("Message already sent — agent picked it up before your edit landed.");
|
|
} else {
|
|
toast.error("Failed to save edit. Please try again.");
|
|
}
|
|
setEditing(false);
|
|
onEditComplete?.();
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [value, entry.content, onSave, onEditComplete]);
|
|
|
|
const positionNumber = entry.position ?? (index ?? 0) + 1;
|
|
const positionLabel = `#${positionNumber}`;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"rounded-md border border-border/60 bg-background/40 px-2 text-sm",
|
|
"hover:border-border transition-colors",
|
|
)}
|
|
>
|
|
{editing ? (
|
|
<EditView
|
|
value={value}
|
|
saving={saving}
|
|
attachments={(entry.attachments ?? []) as QueuedAttachment[]}
|
|
onChange={setValue}
|
|
onSave={handleSave}
|
|
onCancel={handleCancel}
|
|
textareaRef={textareaRef}
|
|
/>
|
|
) : (
|
|
<DisplayView
|
|
entry={entry}
|
|
positionLabel={positionLabel}
|
|
canEdit={canEdit}
|
|
onStartEdit={startEdit}
|
|
onRemove={onRemove}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
);
|