a2d086efb8
* feat(web): @-mention tasks in the chat composer Lets users type @<taskname> in the chat input to reference other tasks. The picked task is inserted as a chip and its metadata (id, workflow_id, step, state) is appended to the prompt as a <kandev-system> block so the agent can act on it via the kandev MCP tools. Extends the existing TipTap mention extension with a new "task" kind; task data comes from the kanban store (current workflow + visited kanbanMulti snapshots), so no backend changes are needed. * fix(web): harden task @-mentions from review feedback (#963) - Sanitize interpolated task strings (title, id, workflow_id, step, state) before they land in the <kandev-system> block so a hostile task title (e.g. from Jira/Linear sync) can't terminate the block early via embedded newlines or angle brackets. - Expand the task-mentions context block on the onSend code path too (used by preview-session-tabs), which was silently dropping the agent-facing task context when chips were present in the editor. - Deduplicate task mentions by taskId in getTaskMentions so mentioning the same task twice no longer produces redundant lines. - Skip stale state.kanban.tasks entries whose workflowStepId isn't in the current workflow's steps to prevent tagging them with the wrong workflow_id. * fix(web): skip task mentions missing workflowId or workflowStepId (#963) CodeRabbit flagged that getTaskMentions was filling missing workflowId / workflowStepId with empty strings, propagating malformed metadata into the prompt block. Skip such nodes during extraction. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
105 lines
2.8 KiB
TypeScript
105 lines
2.8 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
IconAt,
|
|
IconFile,
|
|
IconFolder,
|
|
IconListCheck,
|
|
IconClipboardList,
|
|
} from "@tabler/icons-react";
|
|
import type { MentionItem } from "@/hooks/use-inline-mention";
|
|
import { getFileName, isDirectory } from "@/lib/utils/file-path";
|
|
import { PopupMenu, PopupMenuItem, useMenuItemRefs } from "./popup-menu";
|
|
|
|
type MentionMenuProps = {
|
|
isOpen: boolean;
|
|
isLoading: boolean;
|
|
position?: { x: number; y: number } | null;
|
|
clientRect?: (() => DOMRect | null) | null;
|
|
items: MentionItem[];
|
|
query: string;
|
|
selectedIndex: number;
|
|
onSelect: (item: MentionItem) => void;
|
|
onClose: () => void;
|
|
setSelectedIndex: (index: number) => void;
|
|
};
|
|
|
|
// Get the appropriate icon for an item
|
|
function getItemIcon(item: MentionItem) {
|
|
if (item.kind === "prompt") {
|
|
return <IconAt className="h-4 w-4" />;
|
|
}
|
|
if (item.kind === "plan") {
|
|
return <IconListCheck className="h-4 w-4" />;
|
|
}
|
|
if (item.kind === "task") {
|
|
return <IconClipboardList className="h-4 w-4" />;
|
|
}
|
|
const isDir = isDirectory(item.label);
|
|
return isDir ? <IconFolder className="h-4 w-4" /> : <IconFile className="h-4 w-4" />;
|
|
}
|
|
|
|
// Get the label and description for an item
|
|
function getItemDisplay(item: MentionItem): { label: string; description?: string } {
|
|
if (item.kind === "prompt" || item.kind === "plan" || item.kind === "task") {
|
|
return { label: item.label, description: item.description };
|
|
}
|
|
const name = getFileName(item.label);
|
|
const parent = item.label.slice(0, item.label.length - name.length);
|
|
return { label: name, description: parent || undefined };
|
|
}
|
|
|
|
export function MentionMenu({
|
|
isOpen,
|
|
isLoading,
|
|
position,
|
|
clientRect,
|
|
items,
|
|
query,
|
|
selectedIndex,
|
|
onSelect,
|
|
onClose,
|
|
setSelectedIndex,
|
|
}: MentionMenuProps) {
|
|
const { setItemRef } = useMenuItemRefs(selectedIndex);
|
|
|
|
const emptyState = (
|
|
<div className="px-3 py-1 text-center text-xs text-muted-foreground">
|
|
{(() => {
|
|
if (isLoading) return "Loading...";
|
|
if (query) return "No results found";
|
|
return "Type to search...";
|
|
})()}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<PopupMenu
|
|
isOpen={isOpen}
|
|
position={position ?? null}
|
|
clientRect={clientRect}
|
|
title="Mention tasks, files, prompts"
|
|
selectedIndex={selectedIndex}
|
|
onClose={onClose}
|
|
hasItems={items.length > 0}
|
|
emptyState={emptyState}
|
|
>
|
|
{items.map((item, index) => {
|
|
const { label, description } = getItemDisplay(item);
|
|
return (
|
|
<PopupMenuItem
|
|
key={item.id}
|
|
icon={getItemIcon(item)}
|
|
label={label}
|
|
description={description}
|
|
isSelected={selectedIndex === index}
|
|
onClick={() => onSelect(item)}
|
|
onMouseEnter={() => setSelectedIndex(index)}
|
|
itemRef={setItemRef(index)}
|
|
/>
|
|
);
|
|
})}
|
|
</PopupMenu>
|
|
);
|
|
}
|