"use client"; import { useCallback, useEffect, useMemo, useRef, useState, memo, type RefObject } from "react"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { type ChatInputContainerHandle, type ChatSubmitPayload, type ChatSubmitResult, } from "@/components/task/chat/chat-input-container"; import { MessageList } from "@/components/task/chat/message-list"; import { type MessageListHandle, type LastPromptEdge, getLastUserMessageId, getFirstUserMessageId, resolveLastPromptControls, shouldLoadMoreForTranscriptTarget, } from "@/components/task/chat/message-list-shared"; import { AnchoredLastPromptBar } from "@/components/task/chat/anchored-last-prompt-bar"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; 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 { ResizeHandle } from "./chat/resize-handle"; import { useResizableClarificationOverlay } from "@/hooks/use-resizable-clarification-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 { findUnreadDividerItemId, lastRenderedMessageId } from "@/lib/session-unread-divider"; import { useSessionReadTracking } from "./chat/use-session-read-tracking"; import { useDrainOlderMessages } from "@/components/task/chat/use-drain-older-messages"; import { useAppStore } from "@/components/state-provider"; import type { Message } from "@/lib/types/http"; import { getSessionWorkspacePath } from "@/lib/session-workspace-path"; import { routePanelMouseDown } from "./chat/route-panel-mouse-down"; /** * Cap on how many extra pages the last-prompt background lookup will fetch * (see the `lastPromptLookupPagesRef` effect below) before giving up and * falling back to the transcript's manual "Load older messages" pagination. * 3 pages of 20 covers a long single agent turn without silently draining * an entire long-lived conversation that happens to have no recent user * message at all. */ const MAX_LAST_PROMPT_LOOKUP_PAGES = 3; 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 useUnreadDividerBeforeItemKey( sessionId: string | null, isVisible: boolean, groupedItems: Parameters[0], isInitialMessagesLoading: boolean, ) { const latestMessageId = useMemo(() => lastRenderedMessageId(groupedItems), [groupedItems]); const dividerAnchor = useSessionReadTracking( sessionId, isVisible, latestMessageId, isInitialMessagesLoading, ); return useMemo( () => findUnreadDividerItemId(groupedItems, dividerAnchor), [groupedItems, dividerAnchor], ); } 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); // User-supplied session name wins over the derived profile label, // matching the session tab title precedence (resolveSessionTabTitle). const sessionName = useAppStore((state) => sessionId ? (state.taskSessions.items[sessionId]?.name ?? null) : null, ); return useMemo(() => { if (!profile) return { label: sessionName, name: null }; const parts = profile.label.split(" \u2022 "); const label = sessionName || parts[1] || parts[0] || profile.label; return { label, name: profile.agent_name ?? null }; }, [profile, sessionName]); } type TaskChatPanelProps = { onSend?: (payload: ChatSubmitPayload) => ChatSubmitResult; sessionId?: string | null; taskId?: 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; /** * Whether this panel is the one actually on screen — gates the * Slack-style unread-divider read tracking (see * chat/use-session-read-tracking.ts). Dockview-hosted callers must pass * real tab-activation state (see hooks/use-panel-active.ts); other hosts * (mobile, quick chat, kanban preview) default to true since mounting * already implies visibility for them. */ isVisible?: boolean; }; // eslint-disable-next-line complexity, 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, taskId: taskIdHint = null, onOpenFile, showRequestChangesTooltip = false, onRequestChangesTooltipDismiss, onOpenFileAtLine, hideSessionsDropdown, isVisible = true, }: TaskChatPanelProps) { const isArchived = useIsTaskArchived(); const chatInputRef = useRef(null); useSettingsData(true); const panelState = useChatPanelState({ sessionId, taskId: taskIdHint, onOpenFile, onOpenFileAtLine, }); const { isSending, handleSubmit } = useSubmitHandler(panelState, onSend); const { resolvedSessionId, session, taskId, isWorking, messagesLoading, isInitialMessagesLoading, groupedItems, allMessages, footerActionMessages, permissionsByToolCallId, childrenByParentToolCallId, agentMessageCount, pendingClarification, pendingClarificationGroup, } = panelState; const dividerBeforeItemKey = useUnreadDividerBeforeItemKey( resolvedSessionId, isVisible, groupedItems, isInitialMessagesLoading, ); const { handleCancelTurn } = useChatPanelHandlers(resolvedSessionId, chatInputRef); const { clarificationKey, handleClarificationResolved } = useClarificationKey(agentMessageCount); const { height: clarificationHeight, containerRef: clarificationContainerRef, resetHeight: clarificationResetHeight, resizeHandleProps: clarificationResizeProps, } = useResizableClarificationOverlay(); // Reset the dragged height when the overlay closes so a fresh // clarification starts auto-sized instead of inheriting a stale value. useEffect(() => { if (!pendingClarification) clarificationResetHeight(); }, [pendingClarification, clarificationResetHeight]); const panelRef = useRef(null); const messageListRef = useRef(null); const lastPromptMessageId = useMemo(() => getLastUserMessageId(allMessages), [allMessages]); const lastPromptMessage = useMemo( () => lastPromptMessageId ? (allMessages.find((message) => message.id === lastPromptMessageId) ?? null) : null, [allMessages, lastPromptMessageId], ); const [lastPromptEdge, setLastPromptEdge] = useState("visible"); const showAnchoredPromptBar = useAppStore((state) => state.userSettings.showAnchoredPromptBar); const showScrollToLastPrompt = useAppStore((state) => state.userSettings.showScrollToLastPrompt); const showScrollToStart = useAppStore((state) => state.userSettings.showScrollToStart); const { isMobile } = useResponsiveBreakpoint(); // The anchored bar is a desktop-only, opt-in affordance; mobile always // falls back to the scroll button. const showAnchoredBar = !isMobile && showAnchoredPromptBar; const [anchoredBarHeight, setAnchoredBarHeight] = useState(0); const { anchoredBarVisible, scrollButtonEligible, scrollDirection } = resolveLastPromptControls(lastPromptEdge); const showScrollButton = showScrollToLastPrompt && Boolean(lastPromptMessageId) && scrollButtonEligible; const scrollToLastPrompt = useCallback(() => { if (lastPromptMessageId) { messageListRef.current?.scrollToMessage(lastPromptMessageId, { align: "start" }); } }, [lastPromptMessageId]); const firstMessageId = useMemo(() => getFirstUserMessageId(allMessages), [allMessages]); const [isFirstMessageHidden, setIsFirstMessageHidden] = useState(false); const showScrollToStartButton = showScrollToStart && Boolean(firstMessageId) && isFirstMessageHidden; const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(resolvedSessionId); // A paginated session's `firstMessageId` only reflects the oldest message in // the currently loaded page while `hasMore` is true — jumping there directly // lands on a partial-page boundary, not the transcript's real start. Drain // older pages first so `firstMessageId` (derived from `allMessages` above, // which grows as pages prepend) has settled on the true first prompt by the // time the scroll fires. const [pendingScrollToStart, setPendingScrollToStart] = useState(false); useDrainOlderMessages(resolvedSessionId, pendingScrollToStart && hasMore); useEffect(() => { if (!pendingScrollToStart || hasMore) return; setPendingScrollToStart(false); if (firstMessageId) { messageListRef.current?.scrollToMessage(firstMessageId, { align: "start" }); } }, [pendingScrollToStart, hasMore, firstMessageId]); const scrollToStart = useCallback(() => { if (hasMore) { setPendingScrollToStart(true); return; } if (firstMessageId) { messageListRef.current?.scrollToMessage(firstMessageId, { align: "start" }); } }, [hasMore, firstMessageId]); // Bounded background lookup: the last prompt is usually within a page or // two of a long single agent turn, but a session with no recent user // message at all (e.g. hours of tool-only activity) would otherwise drain // its *entire* history just to power this convenience affordance. Stop // after a handful of pages and fall back to the manual "Load older // messages" pagination the transcript already offers. const lastPromptLookupPagesRef = useRef(0); useEffect(() => { lastPromptLookupPagesRef.current = 0; }, [resolvedSessionId]); useEffect(() => { if (lastPromptMessageId !== null) { lastPromptLookupPagesRef.current = 0; return; } if (isLoadingMore || lastPromptLookupPagesRef.current >= MAX_LAST_PROMPT_LOOKUP_PAGES) return; if (!shouldLoadMoreForTranscriptTarget("last_prompt", allMessages, hasMore)) return; lastPromptLookupPagesRef.current += 1; void loadMore(); }, [allMessages, hasMore, isLoadingMore, loadMore, lastPromptMessageId, 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) => routePanelMouseDown(e, panelRef), [], ); return ( ) : undefined } /> ); }); type ClarificationSectionProps = { pendingClarification: boolean; isArchived: boolean; containerRef: RefObject; resizeProps: { onMouseDown: (e: React.MouseEvent) => void; onDoubleClick: () => void }; height: number | null; messages: readonly Message[] | null | undefined; onResolved: () => void; shortcutScopeRef: RefObject; }; function ClarificationSection({ pendingClarification, isArchived, containerRef, resizeProps, height, messages, onResolved, shortcutScopeRef, }: ClarificationSectionProps) { if (!pendingClarification || isArchived) return null; return (
); } type ChatFooterProps = { isArchived: boolean; chatInputRef: RefObject< import("@/components/task/chat/chat-input-container").ChatInputContainerHandle | null >; clarificationKey: number; onClarificationResolved: () => void; handleSubmit: ReturnType["handleSubmit"]; handleCancelTurn: () => Promise; showRequestChangesTooltip: boolean; onRequestChangesTooltipDismiss?: () => void; panelState: ReturnType; isSending: boolean; hideSessionsDropdown?: boolean; showScrollToLastPrompt: boolean; onScrollToLastPrompt: () => void; lastPromptScrollDirection: "up" | "down"; showScrollToStart: boolean; onScrollToStart: () => void; }; function ChatFooter({ isArchived, chatInputRef, clarificationKey, onClarificationResolved, handleSubmit, handleCancelTurn, showRequestChangesTooltip, onRequestChangesTooltipDismiss, panelState, isSending, hideSessionsDropdown, showScrollToLastPrompt, onScrollToLastPrompt, lastPromptScrollDirection, showScrollToStart, onScrollToStart, }: ChatFooterProps) { if (isArchived) { return (
This task is archived and read-only.
); } return ( ); }