Files
kandev/apps/web/components/task/chat/task-mention-items.ts
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

79 lines
2.5 KiB
TypeScript

import type { MentionItem } from "@/hooks/use-inline-mention";
import type { AppState } from "@/lib/state/store";
type TaskLike = AppState["kanban"]["tasks"][number];
function buildWorkflowNameMap(state: AppState): Map<string, string> {
const m = new Map<string, string>();
for (const w of state.workflows.items) m.set(w.id, w.name);
for (const [wfId, snap] of Object.entries(state.kanbanMulti.snapshots)) {
if (!m.has(wfId) && snap.workflowName) m.set(wfId, snap.workflowName);
}
return m;
}
function buildStepTitleMap(state: AppState): Map<string, string> {
const m = new Map<string, string>();
for (const s of state.kanban.steps) m.set(s.id, s.title);
for (const snap of Object.values(state.kanbanMulti.snapshots)) {
for (const s of snap.steps ?? []) m.set(s.id, s.title);
}
return m;
}
function toMentionItem(
task: TaskLike,
workflowId: string,
workflowName: string,
stepTitle: string,
): MentionItem {
return {
id: `task:${task.id}`,
kind: "task",
label: task.title,
description: `${workflowName} · ${stepTitle}`,
task: {
taskId: task.id,
title: task.title,
workflowId,
workflowStepId: task.workflowStepId,
state: task.state ?? null,
},
onSelect: () => {},
};
}
export function buildTaskMentionItems(
state: AppState,
currentTaskId: string | null,
): MentionItem[] {
const items: MentionItem[] = [];
const seen = new Set<string>();
const workflowNameById = buildWorkflowNameMap(state);
const stepTitleById = buildStepTitleMap(state);
const addTask = (task: TaskLike, workflowId: string) => {
if (task.id === currentTaskId || seen.has(task.id)) return;
seen.add(task.id);
const workflowName = workflowNameById.get(workflowId) ?? "Workflow";
const stepTitle = stepTitleById.get(task.workflowStepId) ?? "Step";
items.push(toMentionItem(task, workflowId, workflowName, stepTitle));
};
if (state.kanban.workflowId) {
// Guard against stale tasks left over from a previous workflow: only add
// entries whose workflowStepId belongs to the current workflow's steps,
// since we tag them with state.kanban.workflowId here.
const activeStepIds = new Set(state.kanban.steps.map((s) => s.id));
for (const t of state.kanban.tasks) {
if (activeStepIds.size > 0 && !activeStepIds.has(t.workflowStepId)) continue;
addTask(t, state.kanban.workflowId);
}
}
for (const [wfId, snap] of Object.entries(state.kanbanMulti.snapshots)) {
for (const t of snap.tasks) addTask(t, wfId);
}
return items;
}