import { useCallback } from "react"; import { getWebSocketClient } from "@/lib/ws/connection"; import { MessageSendError } from "@/lib/chat/message-send-error"; import { useAppStoreApi } from "@/components/state-provider"; import { useQueue } from "./domains/session/use-queue"; import type { ChatSubmitPayload, MessageAttachment, } from "@/components/task/chat/chat-input-container"; import type { ActiveDocument } from "@/lib/state/slices/ui/types"; import type { PlanComment } from "@/lib/state/slices/comments"; import { toBlockquote } from "@/lib/state/slices/comments/format"; import type { ContextFile } from "@/lib/state/context-files-store"; import type { CustomPrompt, Message } from "@/lib/types/http"; import type { TaskMentionData } from "@/hooks/use-inline-mention"; import type { AppState } from "@/lib/state/store"; import type { EntityReference } from "@/lib/types/entity-reference"; import { collectPromptReferenceExpansions, formatPromptReferenceExpansions, } from "@/lib/prompts/expand-prompt-references"; function buildDocumentContext( activeDocument: ActiveDocument | null, planModeEnabled: boolean, planComments?: PlanComment[], ): string { if (!activeDocument) return ""; if (activeDocument.type === "plan") { if (!planModeEnabled) return ""; let context = `\n\n\nACTIVE DOCUMENT: The user is editing the task plan side-by-side with this chat.\nRead the current plan using the plan_get MCP tool to understand the context before responding.\nAny plan modifications should use the plan_update MCP tool.`; if (planComments && planComments.length > 0) { context += `\n\nUser comments on the plan:\n`; for (const c of planComments) { if (c.selectedText) { context += "```\n" + c.selectedText + "\n```\n"; } context += toBlockquote(c.text) + "\n\n"; } } context += `\n`; return context; } return `\n\n\nACTIVE DOCUMENT: The user is editing "${activeDocument.name}" (${activeDocument.path}) side-by-side with this chat.\nRead this file to understand the context before responding.\n`; } function resolveStepTitle(stepId: string, state: AppState): string { const step = state.kanban.steps.find((s) => s.id === stepId); if (step) return step.title; for (const snap of Object.values(state.kanbanMulti.snapshots)) { const found = (snap.steps ?? []).find((s) => s.id === stepId); if (found) return found.title; } return "Step"; } // Strips characters that could break out of the block when // task strings are interpolated verbatim — newlines (close-tag injection) // and angle brackets. Task titles can come from Jira/Linear sync or other // users in a shared workspace, so the data is not trusted. function sanitizeForPrompt(value: string): string { return value.replace(/[\r\n<>]/g, " "); } export function buildTaskMentionsContext(tasks: TaskMentionData[], state: AppState): string { if (tasks.length === 0) return ""; const lines = tasks.map((t) => { const stepTitle = resolveStepTitle(t.workflowStepId, state); const title = sanitizeForPrompt(t.title); const taskId = sanitizeForPrompt(t.taskId); const workflowId = sanitizeForPrompt(t.workflowId); const step = sanitizeForPrompt(stepTitle); const stateSuffix = t.state ? `, state: ${sanitizeForPrompt(t.state)}` : ""; return `- ${title} (id: ${taskId}, workflow_id: ${workflowId}, step: ${step}${stateSuffix})`; }); return ( `\n\n\n` + `REFERENCED TASKS: The user mentioned the following tasks. Use these IDs with the kandev MCP tools ` + `(e.g. \`get_task_conversation_kandev\`, \`update_task_kandev\`, \`get_task_plan_kandev\`) when the user asks you to act on them.\n` + lines.join("\n") + `\n` ); } export function buildContextFilesContext( contextFiles: ContextFile[], prompts: CustomPrompt[], ): string { const files = contextFiles.filter( (f) => !f.path.startsWith("prompt:") && f.path !== "plan:context", ); const promptFiles = contextFiles.filter((f) => f.path.startsWith("prompt:")); let context = ""; if (files.length > 0) { const fileList = files.map((f) => `- ${f.path}`).join("\n"); context += `\n\n\nCONTEXT FILES: The user has attached the following files as context. Read these files to understand what the user is referring to:\n${fileList}\n`; } if (promptFiles.length > 0) { const promptsById = new Map(prompts.map((p) => [p.id, p])); const selectedPrompts = promptFiles .map((f) => promptsById.get(f.path.replace("prompt:", ""))) .filter((prompt): prompt is CustomPrompt => Boolean(prompt)); const selectedPromptNames = new Set(selectedPrompts.map((prompt) => prompt.name)); const promptExpansions = new Map(); const resolved = selectedPrompts .map((prompt) => { for (const expansion of collectPromptReferenceExpansions( prompt.content, prompts, prompt.name, selectedPromptNames, )) { if (!promptExpansions.has(expansion.name)) { promptExpansions.set(expansion.name, expansion.content); } } return `### ${prompt.name}\n${prompt.content}`; }) .filter(Boolean); if (resolved.length > 0) { const expansions = Array.from(promptExpansions, ([name, content]) => ({ name, content })); const expansionContext = formatPromptReferenceExpansions(expansions); context += `\n\n\nCONTEXT PROMPTS: The user has included the following prompt instructions as context:\n${resolved.join("\n\n")}${expansionContext ? "\n\n" + expansionContext : ""}\n`; } } return context; } export interface UseMessageHandlerParams { resolvedSessionId: string | null; taskId: string | null; sessionModel: string | null; activeModel: string | null; planModeEnabled?: boolean; isAgentBusy?: boolean; activeDocument?: ActiveDocument | null; planComments?: PlanComment[]; contextFiles?: ContextFile[]; prompts?: CustomPrompt[]; } type SendMessagePayload = { taskId: string; resolvedSessionId: string; finalMessage: string; modelToSend: string | undefined; planMode: boolean; hasReviewComments?: boolean; attachments?: MessageAttachment[]; contextFilesMeta?: Array<{ path: string; name: string }>; entityReferences?: EntityReference[]; }; export async function sendMessageRequest( payload: SendMessagePayload, ): Promise { const client = getWebSocketClient(); if (!client) { throw new MessageSendError( "connection-unavailable", "Connection unavailable. Reconnect and try again.", ); } const { taskId, resolvedSessionId, finalMessage, modelToSend, planMode, hasReviewComments, attachments, contextFilesMeta, entityReferences, } = payload; const hasAttachments = attachments && attachments.length > 0; return client.request( "message.add", { task_id: taskId, session_id: resolvedSessionId, content: finalMessage, ...(modelToSend && { model: modelToSend }), ...(planMode && { plan_mode: true }), ...(hasReviewComments && { has_review_comments: true }), ...(hasAttachments && { attachments }), ...(contextFilesMeta && { context_files: contextFilesMeta }), ...(entityReferences && { entity_references: entityReferences }), }, hasAttachments ? 30000 : 10000, ); } export function useMessageHandler({ resolvedSessionId, taskId, sessionModel, activeModel, planModeEnabled = false, isAgentBusy = false, activeDocument = null, planComments = [], contextFiles = [], prompts = [], }: UseMessageHandlerParams) { const { queue } = useQueue(resolvedSessionId); const storeApi = useAppStoreApi(); const buildFinalMessage = useCallback( (message: string, inlineMentions?: ContextFile[], inlineTaskMentions?: TaskMentionData[]) => { const allContextFiles = [...contextFiles, ...(inlineMentions || [])]; const documentContext = buildDocumentContext(activeDocument, planModeEnabled, planComments); const contextFilesContext = buildContextFilesContext(allContextFiles, prompts); const taskMentionsContext = inlineTaskMentions?.length ? buildTaskMentionsContext(inlineTaskMentions, storeApi.getState()) : ""; return { finalMessage: message.trim() + documentContext + contextFilesContext + taskMentionsContext, allContextFiles, }; }, [contextFiles, activeDocument, planModeEnabled, planComments, prompts, storeApi], ); const handleSendMessage = useCallback( async (payload: ChatSubmitPayload) => { if (!taskId || !resolvedSessionId) { const error = new MessageSendError( "no-active-session", "No active task session. Start an agent before sending a message.", ); console.error(error.message); throw error; } const { finalMessage, allContextFiles } = buildFinalMessage( payload.message, payload.inlineMentions, payload.inlineTaskMentions, ); const modelToSend = activeModel && activeModel !== sessionModel ? activeModel : undefined; const realFiles = allContextFiles.filter( (f) => !f.path.startsWith("prompt:") && f.path !== "plan:context", ); const contextFilesMeta = realFiles.length > 0 ? realFiles.map((f) => ({ path: f.path, name: f.name })) : undefined; if (isAgentBusy) { const queueAttachments = payload.attachments?.map((att) => ({ type: att.type, data: att.data, mime_type: att.mime_type, name: att.name, delivery_mode: att.delivery_mode, })); await queue({ taskId, content: finalMessage, model: modelToSend, planMode: planModeEnabled, attachments: queueAttachments, entityReferences: payload.entityReferences, }); return; } // Add the returned message to the store directly so the chat updates // even if the session.message.added broadcast is missed (subscription // gap, dropped frame, etc.). addMessage is idempotent on id. const created = await sendMessageRequest({ taskId, resolvedSessionId, finalMessage, modelToSend, planMode: planModeEnabled, hasReviewComments: !!payload.reviewComments?.length, attachments: payload.attachments, contextFilesMeta, entityReferences: payload.entityReferences, }); if (created && created.id && created.session_id) { storeApi.getState().addMessage(created); } }, [ resolvedSessionId, taskId, activeModel, sessionModel, planModeEnabled, isAgentBusy, queue, buildFinalMessage, storeApi, ], ); return { handleSendMessage }; }