"use client"; import { registerAbortHandler } from "@/hooks/useKeyboardShortcuts"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { AgentMessage, AssistantContentBlock, AssistantMessage, BashExecutionMessage, CustomMessage, ExtensionUiRequest, SessionInfo, SessionTreeNode, ToolResultMessage } from "@/lib/types"; import { normalizeCustomPanelLines, parseAnsiLine } from "@/lib/ansi"; import { asBracketedPaste, toTerminalKeyData } from "@/lib/terminal-input"; import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, splitFinalAssistantBlocks } from "@/lib/message-display"; import { MessageView } from "./MessageView"; import { ChatInput, type ChatInputHandle } from "./ChatInput"; import { ChatMinimap, useMessageRefs } from "./ChatMinimap"; import { ExtensionStatusBar } from "./ExtensionStatusBar"; import { useI18n } from "@/hooks/useI18n"; import { useAgentSession, type AgentPhase, type NoticeItem } from "@/hooks/useAgentSession"; import { useAudio } from "@/hooks/useAudio"; import { useDragDrop } from "@/hooks/useDragDrop"; import { useIsMobile } from "@/hooks/useIsMobile"; import type { SessionStatsInfo } from "@/lib/pi-types"; import { captureScrollDistance, getNextVisibleCount, getVisibleRenderWindow, restoreScrollTop, VISIBLE_PAGE_SIZE, } from "@/lib/chat-lazy-load"; interface Props { session: SessionInfo | null; newSessionCwd: string | null; onAgentEnd?: () => void; onSessionCreated?: (session: SessionInfo) => void; onSessionForked?: (newSessionId: string) => void; modelsRefreshKey?: number; chatInputRef?: React.RefObject; onBranchDataChange?: (tree: SessionTreeNode[], activeLeafId: string | null, onLeafChange: (leafId: string | null) => void) => void; onSystemPromptChange?: (prompt: string | null) => void; onSessionStatsChange?: (stats: SessionStatsInfo | null) => void; onSessionStatsPanelOpen?: () => void; onContextUsageChange?: (usage: { percent: number | null; contextWindow: number; tokens: number | null } | null) => void; onOpenFile?: (filePath: string) => void; } function phaseLabel(phase: AgentPhase, t: (key: string, params?: Record) => string): string { if (phase?.kind === "running_tools") { const names = phase.tools.map((t) => t.name); if (names.length === 0) return t("chat.runningTool"); if (names.length === 1) return t("chat.runningNamedTool", { name: names[0] }); if (names.length <= 3) return t("chat.runningTools", { names: names.join(", ") }); return t("chat.runningToolsMore", { names: names.slice(0, 2).join(", "), count: names.length - 2 }); } if (phase?.kind === "waiting_model") return t("chat.waitingModel"); if (phase?.kind === "running_command") return t("chat.runningCommand"); return t("chat.thinking"); } const CHAT_MINIMAP_WIDTH = 36; const CHAT_COLUMN_PADDING = 16; const CHAT_INPUT_RIGHT_PADDING = CHAT_COLUMN_PADDING + CHAT_MINIMAP_WIDTH; function hasFinalAssistantAnswer(message: AgentMessage): boolean { if (message.role !== "assistant") return false; return splitFinalAssistantBlocks(message as AssistantMessage).answerBlocks.some((block) => ( block.type === "image" || (block.type === "text" && block.text.trim().length > 0) )); } function findFinalAssistantIndex(messages: AgentMessage[], userIdx: number, endIdx: number): number { for (let candidateIdx = endIdx - 1; candidateIdx > userIdx; candidateIdx--) { if (hasFinalAssistantAnswer(messages[candidateIdx])) return candidateIdx; } for (let candidateIdx = endIdx - 1; candidateIdx > userIdx; candidateIdx--) { if (messages[candidateIdx]?.role === "assistant") return candidateIdx; } return -1; } function getUserInputText(message: AgentMessage): string | null { if (message.role !== "user") return null; if (typeof message.content === "string") { const text = message.content.trim(); return text.length > 0 ? text : null; } const text = message.content .filter((block) => block.type === "text") .map((block) => block.text) .join("\n") .trim(); return text.length > 0 ? text : null; } function countToolCalls(messages: AgentMessage[], indices: number[]): number { let count = 0; for (const idx of indices) { const msg = messages[idx]; if (msg?.role !== "assistant") continue; count += countToolCallBlocks(getDisplayableAssistantBlocks(msg as AssistantMessage)); } return count; } function hasDisplayableProcessMessage(message: AgentMessage): boolean { if (message.role === "assistant") { return getDisplayableAssistantBlocks(message as AssistantMessage).length > 0; } return message.role === "custom"; } // A user message normally anchors a turn (user prompt → process → final // answer), and the process messages in between get folded into a collapsed // ProcessDetailsGroup. When compaction fires mid-turn, pi drops the original // user prompt and inserts a compaction summary (role "custom", customType // "compaction") in its place; the agent then keeps producing tool calls and a // final answer with no user message left to anchor them. Treat a compaction // summary as an anchor too, otherwise every post-compaction message renders // standalone and never collapses. function isGroupAnchor(message: AgentMessage): boolean { if (message.role === "user") return true; return message.role === "custom" && (message as CustomMessage).customType === "compaction"; } function withAssistantBlocks( message: AssistantMessage, content: AssistantContentBlock[], options: { omitUsage?: boolean } = {}, ): AssistantMessage { const next = { ...message, content }; if (options.omitUsage) next.usage = undefined; return next; } function ProcessDetailsGroup({ messageCount, toolCallCount, children, t }: { messageCount: number; toolCallCount: number; children: ReactNode; t: (key: string, params?: Record) => string }) { const [expanded, setExpanded] = useState(false); const parts = [t("chat.processDetails"), `${messageCount} ${t(messageCount === 1 ? "chat.message" : "chat.messages")}`]; if (toolCallCount > 0) parts.push(`${toolCallCount} ${t(toolCallCount === 1 ? "chat.toolCall" : "chat.toolCalls")}`); return (
{expanded && (
{children}
)}
); } export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsChange, onSessionStatsPanelOpen, onContextUsageChange, onOpenFile }: Props) { const { t } = useI18n(); const { soundEnabled, onSoundToggle, playDoneSound, unlockAudio } = useAudio(); const isMobile = useIsMobile(); // Wrap onAgentEnd to play the completion sound. This is more reliable than // wrapping handleAgentEventRef because useAgentSession overwrites that ref // on every render (it syncs the latest callback), which would blow away an // externally-installed wrapper after the first re-render. const playDoneSoundRef = useRef(playDoneSound); playDoneSoundRef.current = playDoneSound; const soundEnabledRef = useRef(soundEnabled); soundEnabledRef.current = soundEnabled; const wrappedOnAgentEnd = useCallback(() => { if (soundEnabledRef.current) { playDoneSoundRef.current(); } onAgentEnd?.(); }, [onAgentEnd]); // 稳定化 onEditContent 引用,配合 React.memo 防止历史消息重渲染 const handleEditContent = useCallback((content: string) => { chatInputRef?.current?.insertIfEmpty(content); }, [chatInputRef]); const { loading, error, messages, entryIds, streamState, agentRunning, bashRunning, pendingBash, modelNames, modelList, modelError, modelThinkingLevels, modelThinkingLevelMaps, toolPreset, thinkingLevel, retryInfo, contextUsage, forkingEntryId, isCompacting, compactError, compactResult, displayModel: displayModelValue, sessionStats, slashCommands, slashCommandsLoading, queuedMessages, notices, extensionDialog, extensionCustomUi, extensionStatuses, extensionWidgets, respondToExtensionUi, sendExtensionCustomInput, isAutoModelSelection, agentPhase, isNew, sessionIdRef, messagesEndRef, scrollContainerRef, lastUserMsgRef, atBottom, followStreamRef, handleSend, handleAbort, handleFork, handleNavigate, handleModelChange, handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction, handleRecallQueue, handleBuiltinSlashCommand, handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands, scrollToBottom, } = useAgentSession({ session, newSessionCwd, onAgentEnd: wrappedOnAgentEnd, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsPanelOpen, }); const sessionBusy = agentRunning || bashRunning; // Register the abort handler for the global Esc shortcut useEffect(() => { registerAbortHandler(sessionBusy ? handleAbort : null); }, [sessionBusy, handleAbort]); // --- Lazy-load historical messages --- // Only render the last N messages initially. When the user scrolls to the // top, load another page while keeping the scroll position stable. const [visibleCount, setVisibleCount] = useState(VISIBLE_PAGE_SIZE); const sentinelRef = useRef(null); const prevScrollDistanceRef = useRef(null); // IntersectionObserver on the sentinel div at the top of the message list. // When it becomes visible, load the next page of older messages. useEffect(() => { const sentinel = sentinelRef.current; const container = scrollContainerRef.current; if (!sentinel || !container) return; const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) { // Save distance from top before prepending to restore scroll later prevScrollDistanceRef.current = captureScrollDistance(container.scrollHeight, container.scrollTop); setVisibleCount((prev) => getNextVisibleCount(prev)); } }, { root: container, threshold: 0 } ); observer.observe(sentinel); return () => observer.disconnect(); }, [visibleCount, messages.length, scrollContainerRef]); // After visibleCount increases (more messages prepended), restore the // scroll position so the viewport doesn't jump. useEffect(() => { if (prevScrollDistanceRef.current == null) return; const container = scrollContainerRef.current; if (!container) return; container.scrollTop = restoreScrollTop(container.scrollHeight, prevScrollDistanceRef.current); prevScrollDistanceRef.current = null; }, [visibleCount, scrollContainerRef]); // Live-follow the model's streaming output: pin the streaming bubble to // the bottom of the viewport on every chunk while auto-follow is engaged // (the default). Scrolling away from the bottom flips followStreamRef off // inside useAgentSession; scrolling back to the bottom or clicking the // scroll-to-bottom button re-engages it. const streamBubbleRef = useRef(null); useEffect(() => { if (!streamState.isStreaming || !streamState.streamingMessage) return; if (!followStreamRef.current) return; streamBubbleRef.current?.scrollIntoView({ behavior: "auto", block: "end" }); }, [streamState]); // Jump back to the newest content: scroll to the streaming bubble while a // stream is live (its bottom edge is the live bottom), otherwise to the // end of the conversation. Also re-engages auto-follow. const handleScrollToConversationBottom = useCallback(() => { followStreamRef.current = true; if (streamState.isStreaming && streamState.streamingMessage) { streamBubbleRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); } else { scrollToBottom("smooth"); } }, [streamState, followStreamRef, scrollToBottom]); // Push session stats up to AppShell for the top bar. // Compare scalar fields to avoid loops from new object identity each render. const statsKey = sessionStats ? [ sessionStats.sessionId, sessionStats.sessionFile ?? "", sessionStats.sessionName ?? "", sessionStats.userMessages, sessionStats.assistantMessages, sessionStats.toolCalls, sessionStats.toolResults, sessionStats.totalMessages, sessionStats.tokens.input, sessionStats.tokens.output, sessionStats.tokens.cacheRead, sessionStats.tokens.cacheWrite, sessionStats.tokens.total, sessionStats.cost ?? 0, ].join("|") : null; const sessionStatsRef = useRef(sessionStats); sessionStatsRef.current = sessionStats; useEffect(() => { onSessionStatsChange?.(sessionStatsRef.current); }, [statsKey, onSessionStatsChange]); useEffect(() => () => { onSessionStatsChange?.(null); }, [onSessionStatsChange]); // Push context usage up to AppShell as well. const ctxKey = contextUsage ? `${contextUsage.percent ?? "null"}|${contextUsage.contextWindow}|${contextUsage.tokens ?? "null"}` : null; const contextUsageRef = useRef(contextUsage); contextUsageRef.current = contextUsage; useEffect(() => { onContextUsageChange?.(contextUsageRef.current); }, [ctxKey, onContextUsageChange]); useEffect(() => () => { onContextUsageChange?.(null); }, [onContextUsageChange]); const onDrop = useCallback((files: File[]) => { if (sessionBusy) return; chatInputRef?.current?.addImages(files); }, [sessionBusy, chatInputRef]); const { isDragOver, handleDragEnter, handleDragOver, handleDragLeave, handleDrop } = useDragDrop(onDrop); const visibleMessages = messages.filter((m) => m.role === "user" || m.role === "assistant"); const inputHistory = useMemo(() => { const seen = new Set(); const history: string[] = []; for (let i = messages.length - 1; i >= 0; i -= 1) { const text = getUserInputText(messages[i]); if (!text || seen.has(text)) continue; seen.add(text); history.push(text); if (history.length >= 50) break; } return history.reverse(); }, [messages]); const messageRefs = useMessageRefs(visibleMessages.length); const revealHistoryForMinimap = useCallback(() => { setVisibleCount((current) => Math.max(current, messages.length * 2)); }, [messages.length]); const isEmptyNew = isNew && messages.length === 0 && !streamState.isStreaming && !sessionBusy; const messageCwd = session?.cwd ?? newSessionCwd ?? undefined; const availableThinkingLevels = displayModelValue ? (modelThinkingLevels[`${displayModelValue.provider}:${displayModelValue.modelId}`] ?? null) : null; const currentThinkingLevelMap = displayModelValue ? (modelThinkingLevelMaps[`${displayModelValue.provider}:${displayModelValue.modelId}`] ?? null) : null; const chatInputElement = ( ) } isStreaming={sessionBusy} model={displayModelValue} isAutoModelSelection={isAutoModelSelection} modelNames={modelNames} modelList={modelList} modelError={modelError} onModelChange={handleModelChange} onCompact={session || isNew ? handleCompact : undefined} onAbortCompaction={handleAbortCompaction} isCompacting={isCompacting} compactError={compactError} compactResult={compactResult} toolPreset={toolPreset} onToolPresetChange={session || isNew ? handleToolPresetChange : undefined} thinkingLevel={thinkingLevel} onThinkingLevelChange={session || isNew ? handleThinkingLevelChange : undefined} availableThinkingLevels={availableThinkingLevels} thinkingLevelMap={currentThinkingLevelMap} retryInfo={retryInfo} queuedMessages={queuedMessages} inputHistory={inputHistory} onRecallQueue={handleRecallQueue} slashCommands={slashCommands} slashCommandsLoading={slashCommandsLoading} onLoadSlashCommands={loadSlashCommands} onBuiltinCommand={handleBuiltinSlashCommand} soundEnabled={soundEnabled} onSoundToggle={onSoundToggle} onAudioUnlock={unlockAudio} draftKey={session?.id ?? (newSessionCwd ? `new:${newSessionCwd}` : undefined)} cwd={session?.cwd ?? newSessionCwd} /> ); const aboveEditorWidgets = extensionWidgets.filter((widget) => widget.placement !== "belowEditor"); const belowEditorWidgets = extensionWidgets.filter((widget) => widget.placement === "belowEditor"); if (loading) { return (
{t("chat.loadingSession")}
); } if (error) { return (
{error}
); } return (
{isDragOver && !sessionBusy && (
{[0, 0.8, 1.6].map((delay) => (
))}
)} {extensionDialog && ( )} {extensionCustomUi && ( )} {isEmptyNew ? (
π Pi Web
web v{process.env.NEXT_PUBLIC_APP_VERSION ?? "0.0.0"} pi v{process.env.NEXT_PUBLIC_PI_VERSION ?? "0.0.0"}
{chatInputElement}
) : ( <>
{(() => { const toolResultsMap = new Map(); for (const msg of messages) { if (msg.role === "toolResult") { toolResultsMap.set((msg as ToolResultMessage).toolCallId, msg as ToolResultMessage); } } let lastUserIdx = -1; for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === "user") { lastUserIdx = i; break; } } // Anchor for live-tail detection: the last user message, or a // compaction summary when compaction has replaced it mid-turn. // Computed independently from lastUserIdx (which is kept for the // scroll-to-user ref) because a compaction summary can sit after // the last user message and anchor the still-streaming segment. let lastAnchorIdx = -1; for (let i = messages.length - 1; i >= 0; i--) { if (isGroupAnchor(messages[i])) { lastAnchorIdx = i; break; } } const visibleRefIndexByMessage = new Map(); let refIdx = 0; messages.forEach((msg, idx) => { if (msg.role === "user" || msg.role === "assistant") { visibleRefIndexByMessage.set(idx, refIdx++); } }); const attachVisibleRef = (idx: number, refIndex: number) => (el: HTMLDivElement | null) => { messageRefs.current[refIndex] = el; if (idx === lastUserIdx) { (lastUserMsgRef as { current: HTMLDivElement | null }).current = el; } }; const renderMessage = (idx: number, options: { attachRef?: boolean; keyPrefix?: string; messageOverride?: AgentMessage; showTimestamp?: boolean } = {}): ReactNode => { const msg = options.messageOverride ?? messages[idx]; const prevAssistantEntryId = msg.role === "user" && idx > 0 && messages[idx - 1].role === "assistant" ? entryIds[idx - 1] : undefined; const isVisible = msg.role === "user" || msg.role === "assistant"; const currentRefIdx = visibleRefIndexByMessage.get(idx); const keyPrefix = options.keyPrefix ?? "message"; let showTimestamp = false; if (msg.role === "assistant") { showTimestamp = true; for (let j = idx + 1; j < messages.length; j++) { const r = messages[j].role; if (r === "user") break; if (r === "assistant") { showTimestamp = false; break; } } // Hide on the currently-streaming tail (the streaming bubble owns the live timestamp) if (showTimestamp && streamState.isStreaming && idx === messages.length - 1) { showTimestamp = false; } } if (options.showTimestamp !== undefined) showTimestamp = options.showTimestamp; const view = ( 0 ? (messages[idx - 1] as AgentMessage & { timestamp?: number }).timestamp : undefined} sessionId={session?.id ?? sessionIdRef.current ?? undefined} /> ); if (!isVisible || options.attachRef === false || currentRefIdx === undefined) return view; return (
{view}
); }; const rendered: ReactNode[] = []; for (let idx = 0; idx < messages.length;) { const msg = messages[idx]; if (!isGroupAnchor(msg)) { rendered.push(renderMessage(idx)); idx += 1; continue; } const userIdx = idx; let endIdx = userIdx + 1; while (endIdx < messages.length && !isGroupAnchor(messages[endIdx])) endIdx += 1; const finalAssistantIdx = findFinalAssistantIndex(messages, userIdx, endIdx); if (finalAssistantIdx === -1) { for (let renderIdx = userIdx; renderIdx < endIdx; renderIdx++) { rendered.push(renderMessage(renderIdx)); } idx = endIdx; continue; } const isLiveTail = (sessionBusy || streamState.isStreaming) && endIdx === messages.length && userIdx === lastAnchorIdx; if (isLiveTail) { for (let renderIdx = userIdx; renderIdx < endIdx; renderIdx++) { rendered.push(renderMessage(renderIdx)); } idx = endIdx; continue; } rendered.push(renderMessage(userIdx)); const processIndices: number[] = []; for (let processIdx = userIdx + 1; processIdx < finalAssistantIdx; processIdx++) { processIndices.push(processIdx); } const visibleProcessIndices = processIndices.filter((processIdx) => hasDisplayableProcessMessage(messages[processIdx])); const finalAssistant = messages[finalAssistantIdx] as AssistantMessage; const finalSplit = splitFinalAssistantBlocks(finalAssistant); const finalProcessMessage = finalSplit.processBlocks.length > 0 ? withAssistantBlocks(finalAssistant, finalSplit.processBlocks, { omitUsage: true }) : null; const finalAnswerMessage = finalSplit.answerBlocks.length > 0 || getAssistantErrorMessage(finalAssistant) ? withAssistantBlocks(finalAssistant, finalSplit.answerBlocks) : null; const processCount = visibleProcessIndices.length + (finalProcessMessage ? 1 : 0); if (processCount > 0) { const processRefIdx = visibleProcessIndices .map((processIdx) => visibleRefIndexByMessage.get(processIdx)) .find((value): value is number => typeof value === "number") ?? (finalAnswerMessage ? undefined : visibleRefIndexByMessage.get(finalAssistantIdx)); const processGroup = ( {visibleProcessIndices.map((processIdx) => renderMessage(processIdx, { attachRef: false, keyPrefix: "process" }))} {finalProcessMessage && renderMessage(finalAssistantIdx, { attachRef: false, keyPrefix: "process-final", messageOverride: finalProcessMessage, showTimestamp: false })} ); rendered.push(
{ messageRefs.current[processRefIdx] = el; }} > {processGroup}
, ); } if (finalAnswerMessage) { rendered.push(renderMessage(finalAssistantIdx, { messageOverride: finalAnswerMessage })); } for (let renderIdx = finalAssistantIdx + 1; renderIdx < endIdx; renderIdx++) { rendered.push(renderMessage(renderIdx)); } idx = endIdx; } const { startIndex, hasMore } = getVisibleRenderWindow(rendered.length, visibleCount); return ( <> {hasMore && (
{t("chat.loadEarlier", { count: startIndex })}
)} {rendered.slice(startIndex)} ); })()} {streamState.isStreaming && streamState.streamingMessage && (
)} {agentRunning && !streamState.streamingMessage && (
{phaseLabel(agentPhase, t)}
)} {bashRunning && !pendingBash && (
{t("chat.runningCommand")}
)} {pendingBash && ( )}
{isMobile ? null : ( )}
{chatInputElement}
)}
); } function ExtensionWidgets({ widgets }: { widgets: Array<{ key: string; lines: string[] }> }) { if (widgets.length === 0) return null; return (
{widgets.map((widget) => (
{widget.key}
            {widget.lines.join("\n")}
          
))}
); } function NoticeShelf({ notices, floating = false, align = "left" }: { notices: NoticeItem[]; floating?: boolean; align?: "left" | "right" }) { if (notices.length === 0) return null; return (
{notices.map((notice, index) => { const color = notice.type === "error" ? "#ef4444" : notice.type === "warning" ? "#d97706" : notice.type === "success" ? "#10b981" : "var(--accent)"; return (
{notice.message}
); })}
); } type ExtensionDialogRequest = Extract; function ExtensionDialog({ request, onRespond, }: { request: ExtensionDialogRequest; onRespond: (request: ExtensionDialogRequest, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => void; }) { const { t } = useI18n(); const [value, setValue] = useState(request.method === "editor" ? request.prefill ?? "" : ""); useEffect(() => { setValue(request.method === "editor" ? request.prefill ?? "" : ""); }, [request]); const submitValue = () => { if (request.method === "confirm") { onRespond(request, { confirmed: true }); } else { onRespond(request, { value }); } }; return (
{request.title}
{t("chat.extensionRequest")}
{request.method === "confirm" && (
{request.message}
)} {request.method === "select" && (
{request.options.map((option) => ( ))}
)} {request.method === "input" && ( setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submitValue(); if (e.key === "Escape") onRespond(request, { cancelled: true }); }} style={{ width: "100%", padding: "9px 10px", borderRadius: 7, border: "1px solid var(--border)", background: "var(--bg-panel)", color: "var(--text)", outline: "none", fontSize: 13, }} /> )} {request.method === "editor" && (