"use client"; import { registerAbortHandler } from "@/hooks/useKeyboardShortcuts"; import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { AgentMessage, AssistantContentBlock, AssistantMessage, BashExecutionMessage, BlockingExtensionUiRequest, CustomMessage, ExtensionUiRequest, SessionInfo, SessionTreeNode, ToolResultMessage, UserMessage } 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 { extractTurnWrittenFiles, type WrittenFile } from "@/lib/turn-written-files"; 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 { useDragDrop } from "@/hooks/useDragDrop"; import { useIsMobile } from "@/hooks/useIsMobile"; import type { SessionStatsInfo } from "@/lib/pi-types"; import type { AppUpdateResponse } from "@/lib/api-types"; import { captureScrollDistance, getNextVisibleCount, getPromptAnchorSpacerHeight, getVisibleRenderWindow, restoreScrollTop, VISIBLE_PAGE_SIZE, } from "@/lib/chat-lazy-load"; interface Props { session: SessionInfo | null; sessionRunning?: boolean; newSessionCwd: string | null; newSessionDraftKey: string | null; onAgentEnd?: () => void; onAttentionNeeded?: (request: BlockingExtensionUiRequest) => void; onSessionCreated?: (session: SessionInfo, sourceDraftKey: string) => 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; onSystemPromptLoaderChange?: (loader: (() => Promise) | 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; /** Completion sound state + controls, owned by AppShell so tasks finishing in * a non-active workspace can still ring. */ soundEnabled?: boolean; onSoundToggle?: () => void; playDoneSound?: () => void; unlockAudio?: () => void; } function phaseLabel(phase: AgentPhase, t: (key: string, params?: Record) => string): string | null { 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 null; } const CHAT_MINIMAP_WIDTH = 36; const CHAT_COLUMN_PADDING = 16; const CHAT_INPUT_RIGHT_PADDING = CHAT_COLUMN_PADDING + CHAT_MINIMAP_WIDTH; function NewSessionUpdateLink({ label, }: { label: (version: string) => string; }) { const [update, setUpdate] = useState(null); useEffect(() => { const controller = new AbortController(); void fetch("/api/app-update", { signal: controller.signal }) .then(async (response) => { if (!response.ok) return null; return response.json() as Promise; }) .then((result) => { if (result?.updateAvailable && result.latestVersion && result.releaseUrl) { setUpdate(result); } }) .catch(() => { // Update checks are best-effort and must not interrupt a new session. }); return () => controller.abort(); }, []); if (!update) return null; const accessibleLabel = label(update.latestVersion); return ( { event.currentTarget.style.background = "var(--bg-hover)"; }} onMouseLeave={(event) => { event.currentTarget.style.background = "transparent"; }} style={{ display: "inline-flex", alignItems: "center", alignSelf: "center", gap: 3, minHeight: 32, minWidth: 0, padding: "0 4px", background: "transparent", borderRadius: 5, color: "var(--accent)", fontSize: 12, fontWeight: 600, lineHeight: 1.2, textDecoration: "none", transition: "background 0.12s", whiteSpace: "nowrap", }} > v{update.latestVersion} ); } 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, defaultExpanded = false, children, t }: { messageCount: number; toolCallCount: number; defaultExpanded?: boolean; children: ReactNode; t: (key: string, params?: Record) => string }) { const [expanded, setExpanded] = useState(defaultExpanded); 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, sessionRunning, newSessionCwd, newSessionDraftKey, onAgentEnd, onAttentionNeeded, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemPromptLoaderChange, onSessionStatsChange, onSessionStatsPanelOpen, onContextUsageChange, onOpenFile, soundEnabled = true, onSoundToggle, playDoneSound = () => {}, unlockAudio }: Props) { const { t } = useI18n(); 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 soundedExtensionDialogIdRef = useRef(null); const wrappedOnAgentEnd = useCallback(() => { if (soundEnabledRef.current) { playDoneSoundRef.current(); } onAgentEnd?.(); }, [onAgentEnd]); // 稳定化 onEditContent 引用,配合 React.memo 防止历史消息重渲染 const handleEditContent = useCallback((message: UserMessage) => { chatInputRef?.current?.replaceMessage(message); }, [chatInputRef]); const { loading, error, messages, entryIds, streamState, agentRunning, bashRunning, pendingBash, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, toolPreset, thinkingLevel, retryInfo, contextUsage, forkingEntryId, isCompacting, compactError, compactResult, displayModel: displayModelValue, modelSwitching, sessionStats, slashCommands, slashCommandsLoading, queuedMessages, notices, extensionDialog, extensionCustomUi, extensionStatuses, extensionWidgets, respondToExtensionUi, sendExtensionCustomInput, isAutoModelSelection, agentPhase, isNew, sessionIdRef, messagesEndRef, scrollContainerRef, lastUserMsgRef, promptAnchorActive, handleSend, handleAbort, handleFork, handleNavigate, handleModelChange, handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction, handleRecallQueue, handleBuiltinSlashCommand, handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands, scrollUserMsgToTop, } = useAgentSession({ session, sessionRunning, newSessionCwd, newSessionDraftKey, onAgentEnd: wrappedOnAgentEnd, onAttentionNeeded, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemPromptLoaderChange, onSessionStatsPanelOpen, }); const sessionBusy = agentRunning || bashRunning; useEffect(() => { if (!extensionDialog || soundedExtensionDialogIdRef.current === extensionDialog.id) return; soundedExtensionDialogIdRef.current = extensionDialog.id; playDoneSoundRef.current(); }, [extensionDialog]); // 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]); // 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, sessionStats.totalActiveMs ?? 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[]) => { chatInputRef?.current?.addImages(files); }, [chatInputRef]); const { isDragOver, handleDragEnter, handleDragOver, handleDragLeave, handleDrop } = useDragDrop(onDrop); const visibleMessages = messages.filter((m) => m.role === "user" || m.role === "assistant"); // Stable Map identity: `messages` doesn't change during streaming updates // (the streaming message lives in streamState), so memoized MessageViews // skip re-rendering on every message_update event. An inline `new Map()` // here used to defeat MessageView's memo() on each streamed chunk. const toolResultsMap = useMemo(() => { const map = new Map(); for (const msg of messages) { if (msg.role === "toolResult") { map.set((msg as ToolResultMessage).toolCallId, msg as ToolResultMessage); } } return map; }, [messages]); 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 hasStreamingContent = Boolean(streamState.streamingMessage?.content.length); const messageCwd = session?.cwd ?? newSessionCwd ?? undefined; const messageContentRef = useRef(null); const promptAnchorSpacerRef = useRef(null); const promptAnchorSpacerHeightRef = useRef(0); const promptAnchorMeasureFrameRef = useRef(null); const promptAnchorAdjustmentDoneRef = useRef(false); const promptAnchorUpdateRef = useRef<(() => void) | null>(null); useLayoutEffect(() => { const spacer = promptAnchorSpacerRef.current; if (!agentRunning || !promptAnchorActive) { promptAnchorUpdateRef.current = null; promptAnchorSpacerHeightRef.current = 0; promptAnchorAdjustmentDoneRef.current = false; if (spacer) spacer.style.height = ""; return; } const container = scrollContainerRef.current; const messageContent = messageContentRef.current; const userMessage = lastUserMsgRef.current; if (!container || !messageContent || !userMessage || !spacer) return; let disposed = false; const updatePromptAnchorSpacer = () => { if ( disposed || scrollContainerRef.current !== container || messageContentRef.current !== messageContent || lastUserMsgRef.current !== userMessage || promptAnchorSpacerRef.current !== spacer ) return; const containerTop = container.getBoundingClientRect().top; const userMessageTop = userMessage.getBoundingClientRect().top - containerTop + container.scrollTop; const targetTop = Math.max(0, userMessageTop - 16); const contentEnd = spacer.getBoundingClientRect().top - containerTop + container.scrollTop; const nextPromptAnchorSpacerHeight = getPromptAnchorSpacerHeight( targetTop, contentEnd, container.clientHeight, ); const isInitialMeasurement = !promptAnchorAdjustmentDoneRef.current; const needsInitialAdjustment = isInitialMeasurement && nextPromptAnchorSpacerHeight > 0; if (isInitialMeasurement) promptAnchorAdjustmentDoneRef.current = true; if (nextPromptAnchorSpacerHeight === promptAnchorSpacerHeightRef.current) return; promptAnchorSpacerHeightRef.current = nextPromptAnchorSpacerHeight; spacer.style.height = nextPromptAnchorSpacerHeight > 0 ? `${nextPromptAnchorSpacerHeight}px` : ""; if (needsInitialAdjustment) scrollUserMsgToTop(); }; promptAnchorUpdateRef.current = updatePromptAnchorSpacer; const schedulePromptAnchorMeasure = () => { if (disposed || promptAnchorMeasureFrameRef.current !== null) return; promptAnchorMeasureFrameRef.current = requestAnimationFrame(() => { promptAnchorMeasureFrameRef.current = null; updatePromptAnchorSpacer(); }); }; updatePromptAnchorSpacer(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedulePromptAnchorMeasure); observer?.observe(container); observer?.observe(messageContent); observer?.observe(userMessage); return () => { disposed = true; if (promptAnchorUpdateRef.current === updatePromptAnchorSpacer) { promptAnchorUpdateRef.current = null; } observer?.disconnect(); if (promptAnchorMeasureFrameRef.current !== null) { cancelAnimationFrame(promptAnchorMeasureFrameRef.current); promptAnchorMeasureFrameRef.current = null; } }; }, [ agentRunning, lastUserMsgRef, messages.length, promptAnchorActive, scrollContainerRef, scrollUserMsgToTop, ]); useLayoutEffect(() => { promptAnchorUpdateRef.current?.(); }, [streamState.streamingMessage]); const availableThinkingLevels = displayModelValue ? (modelThinkingLevels[`${displayModelValue.provider}:${displayModelValue.modelId}`] ?? null) : null; const currentThinkingLevelMap = displayModelValue ? (modelThinkingLevelMaps[`${displayModelValue.provider}:${displayModelValue.modelId}`] ?? null) : null; const chatInputElement = ( ); if (loading) { return (
{t("chat.loadingSession")}
); } if (error) { return (
{error}
); } return (
{isDragOver && (
{[0, 0.8, 1.6].map((delay) => (
))}
)} {extensionDialog && ( )} {extensionCustomUi && ( )} {isEmptyNew ? (
π Pi Web t("appUpdate.releaseNotes", { version })} />
web v{process.env.NEXT_PUBLIC_APP_VERSION ?? "0.0.0"} pi v{process.env.NEXT_PUBLIC_PI_VERSION ?? "0.0.0"}
{chatInputElement}
) : ( <>
{(() => { 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; writtenFiles?: WrittenFile[] } = {}): 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} writtenFiles={options.writtenFiles} /> ); 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) { // Each tool call is stored as its own assistant entry, so the // final answer alone carries no record of what the turn wrote. // Gather the turn's assistant blocks and derive the file list // from the write/edit calls among them. const turnContent: AssistantContentBlock[] = []; for (let i = userIdx + 1; i <= finalAssistantIdx; i++) { const m = messages[i]; if (m?.role === "assistant") { for (const b of (m as AssistantMessage).content ?? []) turnContent.push(b); } } const writtenFiles = extractTurnWrittenFiles(turnContent, toolResultsMap, messageCwd); rendered.push(renderMessage(finalAssistantIdx, { messageOverride: finalAnswerMessage, writtenFiles })); } 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 && hasStreamingContent && streamState.streamingMessage && ( )} {agentRunning && !hasStreamingContent && agentPhase && (
{phaseLabel(agentPhase, t)}
)} {bashRunning && !pendingBash && (
{t("chat.runningCommand")}
)} {pendingBash && ( )}
{isMobile ? null : ( )}
{chatInputElement}
)}
); } 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" && (