"use client"; import { memo, useCallback, type ReactNode } from "react"; import { Button } from "@kandev/ui/button"; import { IconAlertCircle, IconX } from "@tabler/icons-react"; import { GridSpinner } from "@/components/grid-spinner"; import type { Message, TaskSessionState } from "@/lib/types/http"; import { TASK_DESCRIPTION_SYNTHETIC_ID, type RenderItem } from "@/hooks/use-processed-messages"; import { MessageRenderer } from "@/components/task/chat/message-renderer"; import { TurnGroupMessage } from "@/components/task/chat/messages/turn-group-message"; import { PrepareProgress } from "@/components/session/prepare-progress"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { dismissLastAgentError } from "@/lib/api/domains/session-api"; import { RemediationLink } from "@/components/task/remediation-link"; import { type LastAgentError, lastAgentErrorStamp, readLastAgentError, } from "@/lib/session-last-agent-error"; import { useTranslation } from "react-i18next"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; export type MessageListProps = { items: RenderItem[]; messages: Message[]; /** Action messages rendered after the env prep error status in the footer. */ footerActionMessages?: Message[]; permissionsByToolCallId: Map; childrenByParentToolCallId: Map; taskId?: string; sessionId: string | null; messagesLoading: boolean; isWorking: boolean; sessionState?: TaskSessionState; worktreePath?: string; onOpenFile?: (path: string, repo?: string) => void; /** Render item key (see getItemKey) the unread "New" divider should * appear immediately before; null/undefined renders no divider. */ dividerBeforeItemKey?: string | null; /** Id of the most recent user-sent message, when known. Drives the * scroll-to-last-prompt button (always active) and the anchored-bar * affordance (opt-in, desktop only). */ lastPromptMessageId?: string | null; /** Called whenever the last prompt's position relative to the transcript * viewport changes: fully `"above"` it (scrolled past, further down the * transcript), fully `"below"` it (not yet reached, e.g. browsing earlier * history), or still `"visible"` (some intersection remains). */ onLastPromptEdgeChange?: (edge: LastPromptEdge) => void; /** Id of the earliest user-sent message, when known. Drives the * scroll-to-start button. */ firstMessageId?: string | null; /** Called whenever the first message stops being fully visible (`true`) or * becomes fully visible again (`false`). */ onFirstMessageHiddenChange?: (isHidden: boolean) => void; /** Rendered as the first child inside the scroll container, sticky at its * top — the desktop-only, opt-in anchored last-prompt bar. `null`/`undefined` * when the setting is off or on mobile. */ stickyPromptBar?: ReactNode; /** Current rendered height (px) of the anchored last-prompt bar's pinned * overlay, or 0/undefined when it isn't showing. Lets a target scrolled * to the top of the transcript (e.g. the unread "New" divider) reserve * room for the overlay instead of being covered by it. */ anchoredBarHeight?: number; }; /** Imperative handle exposed by `MessageList`, letting the chat panel scroll * to an arbitrary message (e.g. the last * prompt) from outside the list — from the composer's scroll-up button. */ export type MessageListHandle = { scrollToMessage: ( messageId: string, options?: { align?: "start" | "center"; behavior?: "smooth" | "auto" }, ) => boolean; }; /** Render key for a transcript item: `item.id` for turn-group, prepare- * progress, and agent-error-notice items; the message id for message rows. */ export function getItemKey(item: RenderItem): string { if ( item.type === "turn_group" || item.type === "prepare_progress" || item.type === "agent_error_notice" ) return item.id; return item.message.id; } /** The active turn id, but only while the agent is working — turns no longer * in progress aren't treated as active. */ export function getEffectiveActiveTurnId( activeTurnId: string | null, isWorking: boolean, ): string | null { return isWorking ? activeTurnId : null; } /** Index of the most recent user-authored message, or -1 when there is none. */ function isStoredUserMessage(message: Message): boolean { return message.author_type === "user" && message.id !== TASK_DESCRIPTION_SYNTHETIC_ID; } function findLastUserMessageIndex(messages: Message[]): number { for (let i = messages.length - 1; i >= 0; i--) { if (isStoredUserMessage(messages[i])) return i; } return -1; } /** Id of the most recent user-authored message — the "last prompt" the user * sent. Used to power the transcript's scroll-to-last-prompt affordances. */ export function getLastUserMessageId(messages: Message[]): string | null { const index = findLastUserMessageIndex(messages); return index >= 0 ? messages[index].id : null; } /** Id of the earliest user-authored message — the "first prompt" (usually * the task description). Used to power the transcript's scroll-to-start * affordance. */ export function getFirstUserMessageId(messages: Message[]): string | null { const first = messages.find(isStoredUserMessage); return first ? first.id : null; } /** * Whether the transcript's auto-follow-bottom behavior should force a scroll * to the bottom right now. `false` whenever a user-initiated programmatic * scroll (scroll-to-start / scroll-to-last-prompt) is still in flight or a * layout-rebuild restore is pending — otherwise a message streaming in * mid-scroll would silently snap the transcript back down and cancel the * user's action. */ export function shouldAutoScrollToBottom(params: { isNearBottom: boolean; isProgrammaticScrollLocked: boolean; hasPendingLayoutRestore: boolean; }): boolean { return ( params.isNearBottom && !params.isProgrammaticScrollLocked && !params.hasPendingLayoutRestore ); } /** Where the last prompt sits relative to the transcript viewport: fully * `"above"` it (scrolled past going down), fully `"below"` it (not yet * reached, e.g. still browsing earlier history), or `"visible"` (some * intersection remains). The two-pixel tolerance avoids flickering at * fractional layout boundaries while scrolling settles. */ export type LastPromptEdge = "above" | "below" | "visible"; /** Classifies the last prompt's position relative to the transcript viewport: * fully `"above"` it (scrolled past), fully `"below"` it (not yet reached), * or `"visible"` — with a two-pixel tolerance against flicker. */ export function resolveLastPromptEdge(container: HTMLElement, target: HTMLElement): LastPromptEdge { const containerRect = container.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); const tolerance = 2; if (targetRect.bottom < containerRect.top - tolerance) return "above"; if (targetRect.top > containerRect.bottom + tolerance) return "below"; return "visible"; } /** Derives the anchored bar's visibility and the always-on scroll button's * eligibility/direction from the last prompt's edge state. The anchored bar * only ever represents "you scrolled past it going down" (`above`) — it * must stay closed while the prompt is merely below the viewport because * the user hasn't reached it yet (e.g. browsing earlier history). The * scroll button instead stays eligible in both out-of-view states, but * points toward wherever the prompt actually is. */ export function resolveLastPromptControls(edge: LastPromptEdge): { anchoredBarVisible: boolean; scrollButtonEligible: boolean; scrollDirection: "up" | "down"; } { return { anchoredBarVisible: edge === "above", scrollButtonEligible: edge !== "visible", scrollDirection: edge === "below" ? "down" : "up", }; } /** True when `target` sits entirely within `container`'s visible viewport — * neither edge clipped above or below. Powers the "scroll to start" button's * visibility, which appears as soon as the first prompt is even partially * clipped. */ export function isElementFullyVisible(container: HTMLElement, target: HTMLElement): boolean { const c = container.getBoundingClientRect(); const t = target.getBoundingClientRect(); const tolerance = 0.5; return t.top >= c.top - tolerance && t.bottom <= c.bottom + tolerance; } /** Pixel offset to reserve at the top of the transcript for the anchored * last-prompt bar's pinned overlay, so a scroll-into-view target (namely * the unread "New" divider) lands below it instead of underneath it. * Clamps an unmeasured/negative height to zero and rounds to whole pixels * — callers feed this straight into a CSS `scroll-margin-top`. */ export function anchoredBarScrollOffsetPx(anchoredBarHeight: number | undefined): number { return Math.max(0, Math.round(anchoredBarHeight ?? 0)); } /** Only the latest ordinary agent row in the active turn can be the streaming reply. */ export function getStreamingAgentMessageId(messages: Message[]): string | null { const latestUserIndex = findLastUserMessageIndex(messages); for (let i = messages.length - 1; i > latestUserIndex; i--) { const message = messages[i]; if ( message.author_type === "agent" && (message.type === "message" || message.type === "content" || !message.type) ) { return message.id; } } return null; } /** Id of the final turn-group item in the list, or null when there is none. */ export function getLastTurnGroupId(items: RenderItem[]) { for (let i = items.length - 1; i >= 0; i--) { const item = items[i]; if (item.type === "turn_group") return item.id; } return null; } /** Derives the transcript's loading presentation: whether this is the initial * load and whether the loading spinner should show (suppressed for CREATED * sessions, which are prepare-only, and while the agent is working). */ export function getConversationLoadingState(params: { messagesLoading: boolean; messagesCount: number; isWorking: boolean; sessionState?: TaskSessionState | null; }) { const isInitialLoading = params.messagesLoading && params.messagesCount === 0; const isNonLoadableSession = !params.sessionState || params.sessionState === "CREATED" || params.sessionState === "FAILED" || params.sessionState === "COMPLETED" || params.sessionState === "CANCELLED"; // CREATED sessions are prepare-only: the agent hasn't been started yet, so // there is no conversation to load and the "Start agent" button is the // primary CTA. Suppress the spinner unconditionally to avoid a misleading // overlay racing with that button (synthetic task-description counts as a // message and would otherwise trip the messagesCount > 0 branch). if (params.sessionState === "CREATED") { return { isInitialLoading, showLoadingState: false }; } return { isInitialLoading, showLoadingState: params.messagesLoading && !params.isWorking && (params.messagesCount > 0 || !isNonLoadableSession), }; } /** * Decides whether message-list-native's initial divider-scroll effect may * (re-)apply `scrollIntoView` on the divider this render, versus leaving * whatever position the reader (or a prior correction) already settled on. * * True on the very first successful application (didScrollToDivider is * false) regardless of the other two gates — that first placement must * always happen once a divider target exists. After that, a re-assertion * (e.g. the transcript's initial data arriving in more than one wave, which * can shift where the divider actually lands) is only allowed while BOTH * the reader hasn't interacted yet (isUserScrolling — wheel, touch, or a * key press) AND the visit is still within its short settling window since * mount. Either gate tripping freezes the position for good: a live * message arriving well after the visit has genuinely settled — with no * interaction event to catch, e.g. a scrollbar drag — must never yank the * reader back to the divider. */ export function canReassertDividerScroll(params: { hasDividerTarget: boolean; didScrollToDivider: boolean; isUserScrolling: boolean; isWithinSettlingWindow: boolean; }): boolean { if (!params.hasDividerTarget) return false; if (!params.didScrollToDivider) return true; return !params.isUserScrolling && params.isWithinSettlingWindow; } // The chat banner stays visible until the user explicitly dismisses it, even // after the agent resumes — so the user can read the full error message at // their own pace. The sidebar icon, by contrast, also auto-hides once the // agent posts a new message (see agentErrorMessageForTask). /** Banner showing the session's last agent error, with a dismiss button that * persists until the user explicitly dismisses it. Renders nothing when * there's no error or it was already dismissed. */ export function LastAgentErrorNotice({ sessionId, error, }: { sessionId: string | null; error: LastAgentError | null; }) { const { t } = useTranslation(); const stamp = error ? lastAgentErrorStamp(error) : ""; const dismissedStamp = useAppStore((state) => sessionId ? state.dismissedAgentErrors[sessionId] : undefined, ); const dismissAgentError = useAppStore((state) => state.dismissAgentError); const setTaskSession = useAppStore((state) => state.setTaskSession); const store = useAppStoreApi(); const dismiss = useCallback(() => { if (!sessionId || !stamp) return; void dismissLastAgentError(sessionId, stamp) .then((resp) => { const current = readLastAgentError( store.getState().taskSessions.items[sessionId]?.metadata, ); if (current && lastAgentErrorStamp(current) !== stamp) return; dismissAgentError(sessionId, stamp); setTaskSession(resp.session); }) .catch((err: unknown) => { console.error("Failed to dismiss last agent error", err); }); }, [dismissAgentError, sessionId, stamp, setTaskSession, store]); if (!error || dismissedStamp === stamp) return null; return (
); } /** * Slack-style unread-messages divider: a red rule with a "New" label, * positioned by the caller immediately before the first unread render item * (see hooks/use-processed-messages.ts's findUnreadDividerItemId). */ export function UnreadDivider() { const { t } = useTranslation(); return (
{t("task:new")}
); } /** Transcript status footer: the loading-older indicator, an explicit * load-older button, the conversation loading spinner, and the empty-state * message when there are no messages. */ export function MessageListStatus({ isLoadingMore, hasMore, showLoadingState, messagesLoading, isInitialLoading, messagesCount, onLoadMore, showRecovery = false, }: { isLoadingMore: boolean; hasMore: boolean; showLoadingState: boolean; messagesLoading: boolean; isInitialLoading: boolean; messagesCount: number; /** * Explicitly load the previous page of older messages. Rendered as a button so * older history is always reachable even when the scroll-up IntersectionObserver * fails to re-arm (e.g. pinned at the very top with the sentinel always in view). */ onLoadMore?: () => void; /** Shows the explicit control only after a recoverable pagination failure. */ showRecovery?: boolean; }) { const { t } = useTranslation(); const { isFinePointer } = useResponsiveBreakpoint(); return ( <> {isLoadingMore && hasMore && (
{t("task:loadingOlderMessages")}
)} {hasMore && !isLoadingMore && showRecovery && onLoadMore && (
)} {showLoadingState && (
{t("task:loadingConversation")}
)} {!messagesLoading && !isInitialLoading && messagesCount === 0 && (
{t("task:noMessagesYetStartTheConversation")}
)} ); } export const MessageItem = memo(function MessageItem({ item, sessionId, permissionsByToolCallId, childrenByParentToolCallId, taskId, worktreePath, onOpenFile, isLastGroup, activeTurnId, streamingMessageId, onScrollToMessage, }: { item: RenderItem; sessionId: string | null; permissionsByToolCallId: Map; childrenByParentToolCallId: Map; taskId?: string; worktreePath?: string; onOpenFile?: (path: string, repo?: string) => void; isLastGroup: boolean; activeTurnId?: string | null; streamingMessageId?: string | null; onScrollToMessage: (id: string) => void; }) { if (item.type === "prepare_progress") { return ; } if (item.type === "agent_error_notice") { return ; } if (item.type === "turn_group") { const isContainingTurnActive = Boolean(activeTurnId && item.turnId === activeTurnId); return ( ); } const isContainingTurnActive = Boolean(activeTurnId && item.message.turn_id === activeTurnId); return ( ); });