"use client"; import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { type ChatInputContainerHandle } from "@/components/task/chat/chat-input-container"; import { QueuedMessageIndicator, type QueuedMessageIndicatorHandle, } from "@/components/task/chat/queued-message-indicator"; import { MessageList } from "@/components/task/chat/message-list"; import { useIsTaskArchived } from "./task-archived-context"; import { useChatPanelState } from "./chat/use-chat-panel-state"; import { ChatInputArea, useSubmitHandler, useChatPanelHandlers } from "./chat/chat-input-area"; import { ClarificationInputOverlay } from "./chat/clarification-input-overlay"; import { PanelSearchBar } from "@/components/search/panel-search-bar"; import { SessionSearchHits } from "@/components/task/chat/session-search-hits"; import { usePanelSearch } from "@/hooks/use-panel-search"; import { useSessionSearch } from "@/hooks/domains/session/use-session-search"; import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages"; import { useAppStore } from "@/components/state-provider"; type QueuedOverlayProps = { isQueued: boolean; queuedMessage: { content: string } | null | undefined; isArchived: boolean; indicatorRef: React.RefObject; onCancel: () => void; onUpdate: (content: string) => Promise; onEditComplete: () => void; }; function QueuedMessageOverlay({ isQueued, queuedMessage, isArchived, indicatorRef, onCancel, onUpdate, onEditComplete, }: QueuedOverlayProps) { if (!isQueued || !queuedMessage || isArchived) return null; return (
); } function useClarificationKey(agentMessageCount: number) { const lastCountRef = useRef(agentMessageCount); const [clarificationKey, setClarificationKey] = useState(0); useEffect(() => { lastCountRef.current = agentMessageCount; }, [agentMessageCount]); const handleClarificationResolved = useCallback(() => setClarificationKey((k) => k + 1), []); return { clarificationKey, handleClarificationResolved }; } function SessionSearchOverlay({ search, agentLabel, agentName, }: { search: ReturnType; agentLabel: string | null; agentName: string | null; }) { const currentIdx = search.activeHitId ? search.hits.findIndex((h) => h.id === search.activeHitId) : -1; const total = search.hits.length; const handleNext = useCallback(() => { if (!total) return; const next = search.hits[(Math.max(currentIdx, -1) + 1) % total]; if (next) search.setActiveHit(next.id); }, [search, currentIdx, total]); const handlePrev = useCallback(() => { if (!total) return; const prevIdx = (Math.max(currentIdx, 0) - 1 + total) % total; const prev = search.hits[prevIdx]; if (prev) search.setActiveHit(prev.id); }, [search, currentIdx, total]); if (!search.isOpen) return null; return (
= 0 ? currentIdx + 1 : 0, total }} isLoading={search.isSearching} // Session search already debounces in useDebouncedSearch; skip the // bar's debounce so we don't stack 150ms + 180ms per keystroke. debounceMs={0} />
); } /** Returns the AgentProfileOption for the session's profile, or null. Uses * primitive profile id to avoid getSnapshot-cache errors from returning * fresh objects on every selector call. */ function useSessionAgentProfile(sessionId: string | null | undefined) { const profileId = useAppStore((state) => sessionId ? (state.taskSessions.items[sessionId]?.agent_profile_id ?? null) : null, ); return useAppStore((state) => profileId ? (state.agentProfiles.items.find((p: { id: string }) => p.id === profileId) ?? null) : null, ); } /** Resolves the agent profile name + registry slug for the given session. * Label is "Profile Name" from the "Agent • Profile Name" store label; slug * feeds which fetches the logo by agent type. */ function useSessionAgentIdentity(sessionId: string | null | undefined): { label: string | null; name: string | null; } { const profile = useSessionAgentProfile(sessionId); return useMemo(() => { if (!profile) return { label: null, name: null }; const parts = profile.label.split(" \u2022 "); const label = parts[1] || parts[0] || profile.label; return { label, name: profile.agent_name ?? null }; }, [profile]); } type TaskChatPanelProps = { onSend?: (message: string) => void; sessionId?: string | null; onOpenFile?: (path: string) => void; showRequestChangesTooltip?: boolean; onRequestChangesTooltipDismiss?: () => void; /** Callback to open a file at a specific line (for comment clicks) */ onOpenFileAtLine?: (filePath: string) => void; /** Hide the sessions dropdown (session tabs in dockview replace it) */ hideSessionsDropdown?: boolean; }; // eslint-disable-next-line max-lines-per-function -- composes many sub-panels; each concern already factored into its own hook export const TaskChatPanel = memo(function TaskChatPanel({ onSend, sessionId = null, onOpenFile, showRequestChangesTooltip = false, onRequestChangesTooltipDismiss, onOpenFileAtLine, hideSessionsDropdown, }: TaskChatPanelProps) { const isArchived = useIsTaskArchived(); const chatInputRef = useRef(null); const queuedMessageRef = useRef(null); useSettingsData(true); const panelState = useChatPanelState({ sessionId, onOpenFile, onOpenFileAtLine }); const { isSending, handleSubmit } = useSubmitHandler(panelState, onSend); const { resolvedSessionId, session, task, taskId, isWorking, messagesLoading, groupedItems, allMessages, footerActionMessages, permissionsByToolCallId, childrenByParentToolCallId, agentMessageCount, cancelQueue, pendingClarification, isQueued, queuedMessage, updateQueueContent, } = panelState; const { handleCancelTurn, handleCancelQueue, handleQueueEditComplete } = useChatPanelHandlers( resolvedSessionId, cancelQueue, chatInputRef, ); const { clarificationKey, handleClarificationResolved } = useClarificationKey(agentMessageCount); const panelRef = useRef(null); const { loadMore } = useLazyLoadMessages(resolvedSessionId); const search = useSessionSearch(resolvedSessionId, loadMore); const { label: agentLabel, name: agentName } = useSessionAgentIdentity(resolvedSessionId); usePanelSearch({ containerRef: panelRef, isOpen: search.isOpen, onOpen: search.open, onClose: search.close, }); // The message list has no focus-capturing child (unlike TipTap/xterm in the // plan/terminal panels), so clicking a message leaves focus on . Make // the panel root itself focusable and route non-interactive clicks to it so // Ctrl+F can detect focus within the session panel. const handlePanelMouseDown = useCallback((e: React.MouseEvent) => { const target = e.target as HTMLElement | null; if (!target) return; // Exclude `tabindex="-1"` so we don't match PanelRoot itself (which is // marked focus-receivable but shouldn't short-circuit this handler). if ( target.closest( "input, textarea, select, button, a, [contenteditable], [tabindex]:not([tabindex='-1'])", ) ) { return; } panelRef.current?.focus({ preventScroll: true }); }, []); return ( {pendingClarification && !isArchived && (
)} {isArchived ? (
This task is archived and read-only.
) : ( )}
); });