"use client"; import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { toast } from "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 { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; import { useAppStore } from "@/components/state-provider"; 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 { UserCommentRunBadge } from "./components/user-comment-run-badge"; import { buildCommentTurnContext, type CommentTurnContext } from "./turn-context"; import { groupSessionsForTimeline, groupSortKey, type SessionGroup } from "./session-groups"; import type { TaskComment, TaskDecision, TaskSession, TimelineEvent, } from "@/app/office/tasks/[id]/types"; import { buildLaterAgentReplyMap, buildRunErrorsFromSessions, mergeChatEntries, type ChatEntry, } from "./chat-entries"; const MAX_INLINE_SESSIONS = 50; const AUTOSCROLL_THRESHOLD_PX = 80; type TaskChatProps = { taskId: string; comments: TaskComment[]; timeline?: TimelineEvent[]; sessions?: TaskSession[]; decisions?: TaskDecision[]; reviewers?: string[]; approvers?: string[]; scrollParent?: HTMLElement | null; readOnly?: boolean; onCommentsChanged?: () => void; taskTitle?: string; taskDescription?: string; }; 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 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 ? (s.office.agentProfiles.find((a) => a.id === comment.authorId)?.name ?? comment.authorName ?? "Agent") : "", ); const displayName = isAgent ? resolvedAgentName : "You"; return (
{isAgent ? ( ) : (
)}
{displayName} {isAgent && comment.source === "session" && ( via session )} {comment.status && ( {comment.status} {comment.durationMs != null && ` after ${formatDuration(comment.durationMs)}`} )} {formatRelativeTime(comment.createdAt)}
{!isAgent && comment.runStatus && comment.runStatus !== "finished" && !hasLaterAgentReply && (
)} {comment.toolCalls && comment.toolCalls.length > 0 && ( Worked -- ran {comment.toolCalls.length} commands
{comment.toolCalls.map((tc) => (
{tc.name} {tc.input && {tc.input}}
))}
)} {turn && ( )}
); } // formatDecisionLine renders the human-readable summary for a single // task decision entry in the timeline. Approve rows read like // "CEO approved this task"; request-changes rows append the comment. export function formatDecisionLine(decision: TaskDecision): string { const who = decision.deciderName?.trim() || "Someone"; if (decision.decision === "approved") { return `${who} approved this task`; } const tail = decision.comment ? `: '${decision.comment}'` : ""; return `${who} requested changes${tail}`; } function DecisionTimelineEntry({ decision }: { decision: TaskDecision }) { const isApproval = decision.decision === "approved"; return (
{isApproval ? "approved" : "requested changes"} {formatDecisionLine(decision)} {formatRelativeTime(decision.createdAt)}
); } function TimelineEntry({ event }: { event: TimelineEvent }) { return (
{event.type === "status_change" && event.from && event.to ? ( Status changed from {event.from} to {event.to} ) : ( {event.type.replaceAll("_", " ")} )} {formatRelativeTime(event.at)}
); } 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 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(`Failed to read ${file.name}`); } } }, [setInput], ); 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; }; function ChatInput({ taskId, taskTitle, taskDescription, onSubmitted }: ChatInputProps) { const [input, setInput] = useState(""); const [submitting, setSubmitting] = useState(false); const fileInputRef = useRef(null); const textareaRef = useRef(null); const isUtilityConfigured = useIsUtilityConfigured(); const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({ sessionId: null, taskTitle: taskTitle ?? "", taskDescription: taskDescription ?? "", }); const { handleFileSelect, handlePaste } = useChatInputHandlers(setInput); const handleSubmit = useCallback(async () => { if (!input.trim() || submitting) return; setSubmitting(true); try { await createComment(taskId, { body: input.trim(), author_type: "user" }); setInput(""); onSubmitted?.(); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to send comment"); } finally { setSubmitting(false); } }, [input, submitting, taskId, onSubmitted]); const handleEnhance = useCallback(() => { if (!input.trim()) return; enhancePrompt(input, (enhanced: string) => setInput(enhanced)); }, [input, enhancePrompt]); return (