"use client"; /* eslint-disable max-lines -- this component owns the chat timeline and composer composition. */ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { toast } from "@/lib/toast/sonner"; import { IconCode, IconChevronDown, IconSend, IconPaperclip, IconUser } from "@tabler/icons-react"; import { AgentAvatar } from "@/app/office/components/agent-avatar"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible"; import { EnhancePromptButton } from "@/components/enhance-prompt-button"; import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; import { PromptResultRecovery } from "@/components/prompt-result-recovery"; import { usePromptResultDelivery } from "@/hooks/use-prompt-result-delivery"; import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; import { useAppStore } from "@/components/state-provider"; import { selectOfficeAgentProfiles } from "@/lib/state/slices/office/selectors"; import { selectCommandCount } from "@/lib/state/slices/session/selectors"; import { createComment } from "@/lib/api/domains/office-api"; import { formatRelativeTime } from "@/lib/utils"; import { MarkdownComment } from "./markdown-comment"; import { AgentTurnPanel } from "./components/agent-turn-panel"; import { RunErrorEntry } from "./components/run-error-entry"; import { TaskChatLaunchError } from "./components/task-chat-launch-error"; import { UserCommentRunBadge } from "./components/user-comment-run-badge"; import { buildCommentTurnContext, type CommentTurnContext } from "./turn-context"; import { groupSessionsForTimeline, groupSortKey, type SessionGroup } from "./session-groups"; import { synchronizeInputValue } from "./synchronize-input-value"; import type { TaskComment, TaskDecision, TaskSession, TimelineEvent, } from "@/app/office/tasks/[id]/types"; import { buildLaterAgentReplyMap, buildRunErrorsFromSessions, filterVisibleRunErrors, mergeChatEntries, type ChatEntry, } from "./chat-entries"; import type { TaskRepository } from "@/lib/types/http"; import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; import { useTranslation } from "react-i18next"; import { DecisionTimelineEntry, TimelineEntry } from "./task-chat-timeline-entries"; const MAX_INLINE_SESSIONS = 50; const AUTOSCROLL_THRESHOLD_PX = 80; // A catalog key, not copy: `t()` at module scope would freeze at the boot locale. const PROMPT_INSERTED_MESSAGE_KEY = "task:enhancedPromptInserted"; type TaskChatProps = { taskId: string; workspaceId?: string; comments: TaskComment[]; timeline?: TimelineEvent[]; sessions?: TaskSession[]; decisions?: TaskDecision[]; reviewers?: string[]; approvers?: string[]; scrollParent?: HTMLElement | null; readOnly?: boolean; onCommentsChanged?: () => void; taskTitle?: string; taskDescription?: string; statusSummary?: TaskStatusSummary | null; repositories?: TaskRepository[]; }; function partitionGroups(groups: SessionGroup[]): { visible: SessionGroup[]; older: SessionGroup[]; } { if (groups.length <= MAX_INLINE_SESSIONS) { return { visible: groups, older: [] }; } // Sort ascending by representative start, keep the most recent N. const sorted = [...groups].sort((a, b) => groupSortKey(a).localeCompare(groupSortKey(b))); const cutoff = sorted.length - MAX_INLINE_SESSIONS; return { older: sorted.slice(0, cutoff), visible: sorted.slice(cutoff) }; } function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const seconds = Math.round(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remaining = seconds % 60; return `${minutes}m ${remaining}s`; } function CommentEntry({ comment, taskId, turn, hasLaterAgentReply, }: { comment: TaskComment; taskId: string; turn?: CommentTurnContext; hasLaterAgentReply?: boolean; }) { const { t } = useTranslation(); const isAgent = comment.authorType === "agent"; // Resolve the agent name from the office agents store so renames // flow through automatically. Backend session-bridged comments don't // carry a name; the mapper leaves authorName empty for agents. const resolvedAgentName = useAppStore((s) => isAgent ? (selectOfficeAgentProfiles(s).find((a) => a.id === comment.authorId)?.name ?? comment.authorName ?? t("task:agent")) : "", ); const displayName = isAgent ? resolvedAgentName : t("task:you"); return (
{isAgent ? ( ) : (
)}
{displayName} {isAgent && comment.source === "session" && ( {t("task:viaSession")} )} {comment.status && ( {comment.status} {comment.durationMs != null && t("task:afterDuration", { duration: formatDuration(comment.durationMs) })} )} {formatRelativeTime(comment.createdAt)}
{!isAgent && comment.runStatus && comment.runStatus !== "finished" && !hasLaterAgentReply && (
)} {comment.toolCalls && comment.toolCalls.length > 0 && ( {t("task:workedRanCommands", { count: comment.toolCalls.length })}
{comment.toolCalls.map((tc) => (
{tc.name} {tc.input && {tc.input}}
))}
)} {turn && ( )}
); } async function readFileAsMarkdown(file: File): Promise { if (file.type.startsWith("image/")) { const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(file); }); return `![${file.name}](${dataUrl})`; } const text = await file.text(); return `**${file.name}**\n\`\`\`\n${text.slice(0, 4000)}\n\`\`\``; } function useChatInputHandlers(setInput: React.Dispatch>) { const { t } = useTranslation(); const processFiles = useCallback( async (files: File[]) => { for (const file of files) { try { const md = await readFileAsMarkdown(file); setInput((prev) => (prev ? `${prev}\n\n${md}` : md)); } catch { toast.error(t("task:failedToRead", { name: file.name })); } } }, [setInput, t], ); const handleFileSelect = useCallback( async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; await processFiles(Array.from(files)); e.target.value = ""; }, [processFiles], ); const handlePaste = useCallback( async (e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; const imageFiles: File[] = []; for (const item of Array.from(items)) { if (item.type.startsWith("image/")) { const file = item.getAsFile(); if (file) imageFiles.push(file); } } if (imageFiles.length > 0) { e.preventDefault(); await processFiles(imageFiles); } }, [processFiles], ); return { handleFileSelect, handlePaste }; } type ChatInputProps = { taskId: string; taskTitle?: string; taskDescription?: string; onSubmitted?: () => void; }; type CommentComposerFooterProps = { fileInputRef: React.RefObject; handleFileSelect: (e: React.ChangeEvent) => void; handleEnhance: () => void; isEnhancingPrompt: boolean; isUtilityConfigured: boolean; submitting: boolean; input: string; handleSubmit: () => Promise; pendingResult: ReturnType["pendingResult"]; applyPending: () => void; copyPending: () => Promise; }; function CommentComposerFooter({ fileInputRef, handleFileSelect, handleEnhance, isEnhancingPrompt, isUtilityConfigured, submitting, input, handleSubmit, pendingResult, applyPending, copyPending, }: CommentComposerFooterProps) { const { t } = useTranslation(); const isSendDisabled = submitting || !input.trim(); return ( <>
{t("task:attachFiles")} {t("task:sendComment")}
); } function ChatInput({ taskId, taskTitle, taskDescription, onSubmitted }: ChatInputProps) { const { t } = useTranslation(); const [input, setInput] = useState(""); const [submitting, setSubmitting] = useState(false); const fileInputRef = useRef(null); const inputValueRef = useRef(input); const textareaRef = useRef(null); const setInputAndSync = useCallback((next: React.SetStateAction) => { synchronizeInputValue(inputValueRef, setInput, next); }, []); const isUtilityConfigured = useIsUtilityConfigured(); const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({ sessionId: null, taskTitle: taskTitle ?? "", taskDescription: taskDescription ?? "", }); const promptDelivery = usePromptResultDelivery({ scopeKey: `task-comment:${taskId}`, getCurrent: () => inputValueRef.current, apply: (value) => { setInputAndSync(value); return true; }, }); const { handleFileSelect, handlePaste } = useChatInputHandlers(setInputAndSync); const handleSubmit = useCallback(async () => { const current = inputValueRef.current; if (!current.trim() || submitting) return; setSubmitting(true); try { await createComment(taskId, { body: current.trim(), author_type: "user" }); setInputAndSync(""); onSubmitted?.(); } catch (err) { toast.error(err instanceof Error ? err.message : t("task:failedToSendComment")); } finally { setSubmitting(false); } }, [submitting, taskId, onSubmitted, setInputAndSync, t]); const handleEnhance = useCallback(() => { const current = inputValueRef.current; if (!current.trim()) return; const generation = promptDelivery.captureScope(); void enhancePrompt(current, (result) => { const inserted = promptDelivery.deliver(current, result, generation); if (inserted) { toast.success(t(PROMPT_INSERTED_MESSAGE_KEY)); } return inserted; }); }, [enhancePrompt, promptDelivery, t]); return (