Files
kandev/apps/web/components/task/chat/mention-menu.tsx
T
João Salavisa a2d086efb8 feat(web): add @-mention for tasks in the chat composer (#963)
* 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>
2026-05-21 00:57:12 +01:00

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>
);
}