Files
pi-agent-integrated/pi-web/components/ChatWindow.tsx
T
luckyyzh cb9ec7a869 feat(web): auto-follow streaming output and add scroll-to-bottom button
- ChatWindow: follow the streaming bubble on every chunk (scrollIntoView block:end),
  driven by followStreamRef (true by default, only genuine scroll gestures flip it)
- Remove the agentRunning spacer that pushed content above the viewport at the bottom
- Move the scroll-to-bottom button into the input row (ChatInput scrollToBottomButton slot),
  always visible, dimmed at the bottom; click jumps to the live stream or the end
- useAgentSession: drop window pointerdown as scroll-intent marker (send-click was
  misread as a scroll-away, disabling follow before the stream started); expose
  atBottom/followStreamRef/scrollToBottom
- i18n: add chat.scrollToBottom (en/zh-CN)
2026-08-05 04:46:58 +08:00

1228 lines
49 KiB
TypeScript

"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<ChatInputHandle | null>;
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 | number>) => 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, string | number>) => 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 (
<div style={{ marginBottom: 14 }}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "auto",
minHeight: 24,
padding: "2px 0",
border: "none",
background: "transparent",
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 12,
textAlign: "left",
}}
title={expanded ? t("chat.collapseProcess") : t("chat.expandProcess")}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, transform: expanded ? "rotate(90deg)" : "none", transition: "transform 0.15s" }}>
<polyline points="4 2.5 7.5 6 4 9.5" />
</svg>
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{parts.join(" · ")}
</span>
</button>
{expanded && (
<div style={{ marginTop: 8 }}>
{children}
</div>
)}
</div>
);
}
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<HTMLDivElement>(null);
const prevScrollDistanceRef = useRef<number | null>(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<HTMLDivElement | null>(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<string>();
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 = (
<ChatInput
ref={chatInputRef}
onSend={handleSend}
onAbort={handleAbort}
onSteer={agentRunning ? handleSteer : undefined}
onFollowUp={agentRunning ? handleFollowUp : undefined}
onPromptWithStreamingBehavior={agentRunning ? handlePromptWithStreamingBehavior : undefined}
scrollToBottomButton={
isEmptyNew ? null : (
<button
type="button"
onClick={handleScrollToConversationBottom}
aria-label={t("chat.scrollToBottom")}
title={t("chat.scrollToBottom")}
style={{
flexShrink: 0,
alignSelf: "flex-end",
width: 32,
height: 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid var(--border)",
background: "var(--bg-panel)",
color: "var(--text-muted)",
cursor: "pointer",
opacity: atBottom ? 0.55 : 1,
transition: "opacity 0.15s, background 0.15s",
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
)
}
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 (
<div className="flex h-full items-center justify-center text-text-muted">
{t("chat.loadingSession")}
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center text-red-400">
{error}
</div>
);
}
return (
<div
className="relative flex h-full flex-col overflow-hidden"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragOver && !sessionBusy && (
<div className="pointer-events-none absolute inset-0 z-50 flex animate-[drop-zone-in_0.15s_ease_both] items-center justify-center bg-[rgba(37,99,235,0.06)] backdrop-blur-[1px]">
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
{[0, 0.8, 1.6].map((delay) => (
<div
key={delay}
className="absolute h-[720px] w-[720px] rounded-full border-[1.5px] border-solid border-[rgba(37,99,235,0.5)] animate-[drop-ripple_2.4s_ease-out_infinite_backwards]"
style={{ transformOrigin: "center", animationDelay: `${delay}s` }}
/>
))}
</div>
<svg
width="280" height="280" viewBox="0 0 140 140" fill="none" xmlns="http://www.w3.org/2000/svg"
className="drop-shadow-[0_6px_18px_rgba(37,99,235,0.18)]"
>
<rect x="28" y="44" width="84" height="60" rx="8" fill="rgba(37,99,235,0.08)" stroke="rgba(37,99,235,0.50)" strokeWidth="1.8"/>
<path d="M36 100 L54 72 L68 88 L80 74 L104 100Z" fill="rgba(37,99,235,0.16)" stroke="rgba(37,99,235,0.40)" strokeWidth="1.4" strokeLinejoin="round"/>
<circle cx="96" cy="58" r="8" fill="rgba(37,99,235,0.22)" stroke="rgba(37,99,235,0.55)" strokeWidth="1.6"/>
<g stroke="rgba(37,99,235,0.45)" strokeWidth="1.4" strokeLinecap="round">
<line x1="96" y1="46" x2="96" y2="43"/>
<line x1="96" y1="70" x2="96" y2="73"/>
<line x1="84" y1="58" x2="81" y2="58"/>
<line x1="108" y1="58" x2="111" y2="58"/>
<line x1="87.5" y1="49.5" x2="85.4" y2="47.4"/>
<line x1="104.5" y1="66.5" x2="106.6" y2="68.6"/>
<line x1="104.5" y1="49.5" x2="106.6" y2="47.4"/>
<line x1="87.5" y1="66.5" x2="85.4" y2="68.6"/>
</g>
</svg>
</div>
)}
{extensionDialog && (
<ExtensionDialog
request={extensionDialog}
onRespond={respondToExtensionUi}
/>
)}
{extensionCustomUi && (
<ExtensionCustomPanel
request={extensionCustomUi}
onInput={sendExtensionCustomInput}
/>
)}
{isEmptyNew ? (
<div className="flex flex-1 flex-col items-center justify-center overflow-y-auto px-4 py-8">
<div className="w-full max-w-[820px]">
<div
className="mb-3"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginLeft: 16,
marginRight: 52,
fontFamily: "var(--font-mono)",
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 10, minWidth: 0, flex: 1, lineHeight: 1.4, overflow: "hidden" }}>
<span style={{ fontSize: 28, fontWeight: 700, letterSpacing: 0, color: "var(--text)", flexShrink: 0, whiteSpace: "nowrap" }}>π</span>
<span style={{ fontSize: 22, color: "var(--text)", fontWeight: 700, letterSpacing: 0, flexShrink: 0, whiteSpace: "nowrap" }}>Pi Web</span>
</div>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 2, flexShrink: 0 }}>
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>
web <span style={{ color: "var(--text)" }}>v{process.env.NEXT_PUBLIC_APP_VERSION ?? "0.0.0"}</span>
</span>
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>
pi <span style={{ color: "var(--text)" }}>v{process.env.NEXT_PUBLIC_PI_VERSION ?? "0.0.0"}</span>
</span>
</div>
</div>
<NoticeShelf notices={notices} align="right" />
{chatInputElement}
</div>
</div>
) : (
<>
<div className="relative flex flex-1 overflow-hidden">
<div
style={{
position: "absolute",
top: 12,
left: 0,
right: isMobile ? 0 : CHAT_MINIMAP_WIDTH,
zIndex: 40,
padding: `0 ${CHAT_COLUMN_PADDING}px`,
pointerEvents: "none",
}}
>
<div style={{ maxWidth: 820, margin: "0 auto" }}>
<NoticeShelf notices={notices} floating align="right" />
</div>
</div>
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto pt-4 [scrollbar-width:none]">
<div style={{ padding: `0 ${CHAT_COLUMN_PADDING}px` }}>
<div style={{ maxWidth: 820, margin: "0 auto" }}>
<ExtensionWidgets widgets={aboveEditorWidgets} />
{(() => {
const toolResultsMap = new Map<string, ToolResultMessage>();
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<number, number>();
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 = (
<MessageView
key={`${keyPrefix}-view-${idx}`}
message={msg}
toolResults={toolResultsMap}
modelNames={modelNames}
cwd={messageCwd}
onOpenFile={onOpenFile}
entryId={entryIds[idx]}
onFork={sessionBusy || isNew || (idx === 0 && msg.role === "user") ? undefined : handleFork}
forking={forkingEntryId === entryIds[idx]}
onNavigate={sessionBusy ? undefined : handleNavigate}
prevAssistantEntryId={sessionBusy ? undefined : prevAssistantEntryId}
onEditContent={handleEditContent}
showTimestamp={showTimestamp}
prevTimestamp={idx > 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 (
<div key={`${keyPrefix}-${idx}`} ref={attachVisibleRef(idx, currentRefIdx)}>
{view}
</div>
);
};
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 = (
<ProcessDetailsGroup
messageCount={processCount}
t={t}
toolCallCount={countToolCalls(messages, visibleProcessIndices) + countToolCallBlocks(finalSplit.processBlocks)}
>
{visibleProcessIndices.map((processIdx) => renderMessage(processIdx, { attachRef: false, keyPrefix: "process" }))}
{finalProcessMessage && renderMessage(finalAssistantIdx, { attachRef: false, keyPrefix: "process-final", messageOverride: finalProcessMessage, showTimestamp: false })}
</ProcessDetailsGroup>
);
rendered.push(
<div
key={`process-group-${userIdx}-${finalAssistantIdx}`}
ref={processRefIdx === undefined ? undefined : (el) => { messageRefs.current[processRefIdx] = el; }}
>
{processGroup}
</div>,
);
}
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 && (
<div ref={sentinelRef} className="py-3 text-center text-xs text-text-muted">
{t("chat.loadEarlier", { count: startIndex })}
</div>
)}
{rendered.slice(startIndex)}
</>
);
})()}
{streamState.isStreaming && streamState.streamingMessage && (
<div ref={streamBubbleRef}>
<MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} cwd={messageCwd} onOpenFile={onOpenFile} />
</div>
)}
{agentRunning && !streamState.streamingMessage && (
<div className="py-2 text-[13px] text-text-muted">
<span className="animate-[pulse_1.5s_infinite]">{phaseLabel(agentPhase, t)}</span>
</div>
)}
{bashRunning && !pendingBash && (
<div className="py-2 text-[13px] text-text-muted">
<span className="animate-[pulse_1.5s_infinite]">{t("chat.runningCommand")}</span>
</div>
)}
{pendingBash && (
<MessageView
message={{
role: "bashExecution",
command: pendingBash.command,
output: "",
excludeFromContext: pendingBash.excludeFromContext,
} as BashExecutionMessage}
sessionId={session?.id ?? sessionIdRef.current ?? undefined}
/>
)}
<div ref={messagesEndRef} />
</div>
</div>
</div>
{isMobile ? null : (
<ChatMinimap
messages={messages}
streamingMessage={streamState.streamingMessage}
scrollContainer={scrollContainerRef}
messageRefs={messageRefs}
onRevealHistory={revealHistoryForMinimap}
/>
)}
</div>
<div className="relative">
<div
style={{
padding: `0 ${CHAT_COLUMN_PADDING}px`,
paddingRight: isMobile ? CHAT_COLUMN_PADDING : CHAT_INPUT_RIGHT_PADDING,
}}
>
<div style={{ maxWidth: 820, margin: "0 auto" }}>
<ExtensionWidgets widgets={belowEditorWidgets} />
</div>
</div>
{chatInputElement}
<ExtensionStatusBar statuses={extensionStatuses} />
</div>
</>
)}
</div>
);
}
function ExtensionWidgets({ widgets }: { widgets: Array<{ key: string; lines: string[] }> }) {
if (widgets.length === 0) return null;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 10 }}>
{widgets.map((widget) => (
<div
key={widget.key}
style={{
border: "1px solid var(--border)",
borderRadius: 7,
background: "var(--bg-panel)",
overflow: "hidden",
}}
>
<div style={{ padding: "5px 9px", borderBottom: "1px solid var(--border)", color: "var(--text-dim)", fontSize: 11, fontFamily: "var(--font-mono)" }}>
{widget.key}
</div>
<pre style={{ margin: 0, padding: "8px 9px", color: "var(--text-muted)", fontSize: 12, lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word", fontFamily: "var(--font-mono)" }}>
{widget.lines.join("\n")}
</pre>
</div>
))}
</div>
);
}
function NoticeShelf({ notices, floating = false, align = "left" }: { notices: NoticeItem[]; floating?: boolean; align?: "left" | "right" }) {
if (notices.length === 0) return null;
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: align === "right" ? "flex-end" : "stretch",
marginBottom: floating ? 0 : 10,
}}
>
{notices.map((notice, index) => {
const color = notice.type === "error"
? "#ef4444"
: notice.type === "warning"
? "#d97706"
: notice.type === "success"
? "#10b981"
: "var(--accent)";
return (
<div
key={notice.id}
className="notice-shelf-item"
style={{
display: "flex",
alignItems: "center",
gap: 10,
minHeight: 60,
height: 60,
maxHeight: 60,
marginBottom: index === notices.length - 1 ? 0 : 6,
overflow: "hidden",
borderRadius: 14,
border: "1px solid color-mix(in srgb, var(--border) 70%, transparent)",
background: "var(--bg)",
color: "var(--text-muted)",
width: "fit-content",
maxWidth: "min(100%, 620px)",
boxShadow: floating
? "0 1px 2px rgba(15,23,42,0.05), 0 10px 28px -14px rgba(15,23,42,0.24)"
: "0 1px 2px rgba(15,23,42,0.04), 0 8px 24px -12px rgba(15,23,42,0.10)",
fontSize: 18,
lineHeight: 1.45,
transformOrigin: "top center",
animation: notice.exiting
? "notice-shelf-out 0.18s ease-in forwards"
: "notice-shelf-in 0.18s ease-out both",
padding: "0 12px",
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: color,
flexShrink: 0,
}}
/>
<span style={{ padding: "14px 0", minWidth: 0, maxWidth: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{notice.message}
</span>
</div>
);
})}
</div>
);
}
type ExtensionDialogRequest = Extract<ExtensionUiRequest, { method: "select" | "confirm" | "input" | "editor" }>;
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 (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 90,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 20,
background: "rgba(0,0,0,0.18)",
}}
>
<div
role="dialog"
aria-modal="true"
style={{
width: "min(560px, 100%)",
border: "1px solid var(--border)",
borderRadius: 8,
background: "var(--bg)",
boxShadow: "0 20px 60px rgba(0,0,0,0.28)",
overflow: "hidden",
}}
>
<div style={{ padding: "12px 14px", borderBottom: "1px solid var(--border)" }}>
<div style={{ color: "var(--text)", fontSize: 14, fontWeight: 650 }}>{request.title}</div>
<div style={{ marginTop: 3, color: "var(--text-dim)", fontSize: 11, fontFamily: "var(--font-mono)" }}>{t("chat.extensionRequest")}</div>
</div>
<div style={{ padding: 14 }}>
{request.method === "confirm" && (
<div style={{ color: "var(--text-muted)", fontSize: 13, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>{request.message}</div>
)}
{request.method === "select" && (
<div style={{ display: "grid", gap: 8 }}>
{request.options.map((option) => (
<button
key={option}
onClick={() => onRespond(request, { value: option })}
style={{
width: "100%",
padding: "9px 10px",
borderRadius: 7,
border: "1px solid var(--border)",
background: "var(--bg-panel)",
color: "var(--text)",
cursor: "pointer",
textAlign: "left",
fontSize: 13,
}}
>
{option}
</button>
))}
</div>
)}
{request.method === "input" && (
<input
autoFocus
value={value}
placeholder={request.placeholder}
onChange={(e) => 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" && (
<textarea
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") onRespond(request, { cancelled: true });
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") submitValue();
}}
style={{
width: "100%",
minHeight: 220,
padding: 10,
borderRadius: 7,
border: "1px solid var(--border)",
background: "var(--bg-panel)",
color: "var(--text)",
outline: "none",
resize: "vertical",
fontSize: 13,
lineHeight: 1.55,
fontFamily: "var(--font-mono)",
}}
/>
)}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, padding: "10px 14px", borderTop: "1px solid var(--border)", background: "var(--bg-panel)" }}>
<button
onClick={() => onRespond(request, { cancelled: true })}
style={{
padding: "6px 10px",
borderRadius: 6,
border: "1px solid var(--border)",
background: "var(--bg)",
color: "var(--text-muted)",
cursor: "pointer",
}}
>
{t("chat.cancel")}
</button>
{request.method === "confirm" ? (
<button
onClick={submitValue}
style={{
padding: "6px 10px",
borderRadius: 6,
border: "1px solid var(--accent)",
background: "var(--accent)",
color: "#fff",
cursor: "pointer",
}}
>
{t("chat.confirm")}
</button>
) : request.method !== "select" ? (
<button
onClick={submitValue}
style={{
padding: "6px 10px",
borderRadius: 6,
border: "1px solid var(--accent)",
background: "var(--accent)",
color: "#fff",
cursor: "pointer",
}}
>
{t("chat.submit")}
</button>
) : null}
</div>
</div>
</div>
);
}
type ExtensionCustomRequest = Extract<ExtensionUiRequest, { method: "custom" }>;
function renderAnsiLine(line: string, keyPrefix: string): ReactNode[] {
return parseAnsiLine(line).map((segment, index) => (
Object.keys(segment.style).length > 0
? <span key={`${keyPrefix}-${index}`} style={segment.style}>{segment.text}</span>
: segment.text
));
}
function ExtensionCustomPanel({
request,
onInput,
}: {
request: ExtensionCustomRequest;
onInput: (request: ExtensionCustomRequest, data: string) => void;
}) {
const { t } = useI18n();
const inputRef = useRef<HTMLTextAreaElement>(null);
const composingRef = useRef(false);
const displayLines = normalizeCustomPanelLines(request.lines);
useEffect(() => {
inputRef.current?.focus();
}, [request.id]);
return (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 95,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 20,
background: "rgba(0,0,0,0.18)",
}}
>
<div
role="dialog"
aria-modal="true"
onClick={(event) => {
if (!(event.target as HTMLElement).closest("button")) inputRef.current?.focus();
}}
style={{
position: "relative",
width: "min(920px, 100%)",
maxHeight: "min(760px, calc(100vh - 40px))",
border: "1px solid var(--border)",
borderRadius: 8,
background: "var(--bg)",
boxShadow: "0 20px 60px rgba(0,0,0,0.28)",
overflow: "hidden",
outline: "none",
}}
>
<textarea
ref={inputRef}
aria-label={t("chat.extensionInput")}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
onKeyDown={(event) => {
if (composingRef.current || event.nativeEvent.isComposing) return;
const data = toTerminalKeyData(event);
if (!data) return;
event.preventDefault();
event.stopPropagation();
onInput(request, data);
}}
onInput={(event) => {
if (composingRef.current || event.nativeEvent.isComposing) return;
const text = event.currentTarget.value;
event.currentTarget.value = "";
if (text) onInput(request, text);
}}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={(event) => {
composingRef.current = false;
const input = event.currentTarget;
queueMicrotask(() => {
const text = input.value;
input.value = "";
if (text) onInput(request, text);
});
}}
onPaste={(event) => {
event.preventDefault();
const text = event.clipboardData.getData("text");
if (text) onInput(request, asBracketedPaste(text));
}}
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
border: 0,
opacity: 0,
pointerEvents: "none",
}}
/>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "10px 12px", borderBottom: "1px solid var(--border)" }}>
<div style={{ color: "var(--text)", fontSize: 13, fontWeight: 650 }}>{t("chat.extensionPanel")}</div>
<button
onClick={() => onInput(request, "\x03")}
style={{
padding: "5px 9px",
borderRadius: 6,
border: "1px solid var(--border)",
background: "var(--bg-panel)",
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 12,
}}
>
{t("chat.close")}
</button>
</div>
<pre
style={{
margin: 0,
padding: 14,
maxHeight: "calc(min(760px, 100vh - 40px) - 48px)",
overflow: "auto",
background: "var(--bg-panel)",
color: "var(--text)",
fontFamily: "var(--font-mono)",
fontSize: 13,
lineHeight: 1.45,
whiteSpace: "pre",
}}
>
{(displayLines.length ? displayLines : [""]).map((line, index, allLines) => (
<Fragment key={index}>
{renderAnsiLine(line, `line-${index}`)}
{index < allLines.length - 1 ? "\n" : null}
</Fragment>
))}
</pre>
</div>
</div>
);
}