Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 2 additions & 19 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
import { WorkspaceMenuBar } from "../WorkspaceMenuBar/WorkspaceMenuBar";
import { useMainPaneAgentInterfaceView } from "@/browser/features/RightSidebar/ArtifactsTab/agentInterfaceView";
import type { AgentInterfaceView } from "@/browser/features/RightSidebar/ArtifactsTab/agentInterfaceViewState";
import { LazyFeature } from "../LazyFeature/LazyFeature";
import { AgentInterfaceStatusView } from "@/browser/features/RightSidebar/ArtifactsTab/AgentInterfaceStatusView";
import { WorkspaceFooterBar } from "./WorkspaceFooterBar";
import type { DisplayedMessage, QueuedMessage as QueuedMessageData } from "@/common/types/message";
import type { HeldInput as HeldInputData } from "@/common/orpc/types";
Expand Down Expand Up @@ -138,23 +138,6 @@
type CustomEventPayloads,
} from "@/common/constants/events";

// The status page loads with the Agent interface view only (#7510): through ArtifactViewer it
// pulls the artifact frame, which the workspace page must not download while the view is off.
const AgentInterfaceStatusView = React.lazy(async () => ({
default: (await import("@/browser/features/RightSidebar/ArtifactsTab/AgentInterfaceStatusView"))
.AgentInterfaceStatusView,
}));

/** The status page, fetched and mounted only while the view shows it (the lazy open condition). */
function LazyStatusView(props: React.ComponentProps<typeof AgentInterfaceStatusView>) {
if (props.view !== "status") return null;
return (
<LazyFeature name="Status page">
<AgentInterfaceStatusView {...props} />
</LazyFeature>
);
}

const TRANSCRIPT_ONLY_NOTICE =
"This workspace's worktree is no longer available. This is a read-only chat transcript kept for historical and usage-tracking reasons.";

Expand Down Expand Up @@ -462,1500 +445,1500 @@
return editing ? null : queued;
}

export const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
const {
workspaceId,
projectName,
workspaceName,
namedWorkspacePath,
runtimeConfig,
onOpenTerminal,
} = props;
const workspaceState = useWorkspaceState(workspaceId);
const chatTranscriptFullWidth = useChatTranscriptFullWidth();
const [transcriptDensity] = useTranscriptDensity();
const { api } = useAPI();
const liveApi = useLiveAPI();
const { workspaceMetadata } = useWorkspaceContext();
const storeRaw = useWorkspaceStoreRaw();
const aggregator = useWorkspaceAggregator(workspaceId);
const workspaceUsage = useWorkspaceUsage(workspaceId);
const reviews = useReviews(workspaceId);
const { clearError: clearBackgroundBashError } = useBackgroundBashError();

// Agent interface view: the transcript stays mounted (hidden) under the status page, so its
// scroll position survives a round trip; the status page unmounts while the transcript shows.
const showStatusView = props.agentInterfaceView === "status";

// Transcript-only workspaces preserve historical chat and usage after the worktree is deleted,
// so the transcript stays readable while new sends remain disabled.
const meta = workspaceMetadata.get(workspaceId);
const hasRepository = hasWorkspaceRepository(meta);
const transcriptOnly = meta?.transcriptOnly ?? false;
const isPreStreamAgentTask =
Boolean(meta?.parentWorkspaceId) && isBlockedPreStreamTaskStatus(meta?.taskStatus);
const preStreamAgentTaskLabel = meta?.taskStatus === "starting" ? "Starting" : "Queued";
const queuedAgentTaskPrompt =
isPreStreamAgentTask &&
typeof meta?.taskPrompt === "string" &&
meta.taskPrompt.trim().length > 0
? meta.taskPrompt
: null;
const shouldShowQueuedAgentTaskPrompt =
Boolean(queuedAgentTaskPrompt) && (workspaceState?.messages.length ?? 0) === 0;

const { has1MContext } = useProviderOptions();
// Resolve 1M context per-model (uses the pending model for the current workspace)
const pendingSendOptions = useSendMessageOptions(workspaceId);
const pendingModel = pendingSendOptions.model;
const use1M = has1MContext(pendingModel);

const { config: providersConfig } = useProvidersConfig();

// First-paint readiness barrier: all decoration data sources known (or the
// resilience deadline passed). Gates the reveal so decorations can't pop in
// after the transcript is visible.
const chatViewDataReady = useChatViewDataReady(workspaceId);

const { threshold: autoCompactionThreshold, rolloverEnabled } = useAutoCompactionSettings(
workspaceId,
pendingModel
);

const [queuedActionErrorState, setQueuedActionErrorState] = useState<{
workspaceId: string;
messageId: string;
error: string;
} | null>(null);
const queuedActionError =
queuedActionErrorState?.workspaceId === workspaceId &&
queuedActionErrorState.messageId === workspaceState?.queuedMessage?.id
? queuedActionErrorState.error
: null;

const [editingState, setEditingState] = useState(() => ({
workspaceId,
message: undefined as EditingMessageState | undefined,
}));
const editingMessage =
editingState.workspaceId === workspaceId ? editingState.message : undefined;
const setEditingMessage = useCallback(
(message: EditingMessageState | undefined) => {
// Any change of edit target ends the conflict recovery of the previous edit (no-op
// when none is pending); only the composer's own updater keeps a request alive.
storeRaw.cancelTranscriptRefresh(workspaceId);
setEditingState({
workspaceId,
message: transcriptOnly ? undefined : message,
});
},
[storeRaw, workspaceId, transcriptOnly]
);
const updateEditingMessage = (update: (current: EditingMessageState) => EditingMessageState) => {
setEditingState((previous) =>
previous.workspaceId === workspaceId && previous.message
? { ...previous, message: update(previous.message) }
: previous
);
};
// The workspace with an unresolved edit send: no edit starts there meanwhile (#5226).
const [editSendPendingIn, setEditSendPendingIn] = useState<string | null>(null);
const editSendPending = editSendPendingIn === workspaceId;
const handleEditSendPendingChange = (pending: boolean) =>
setEditSendPendingIn((current) =>
pending ? workspaceId : current === workspaceId ? null : current
);
/**
* Enter edit mode with content evidence of the rows the edit deletes. A row the aggregator
* does not hold cannot be fenced (a real state, not a bug): stay out of edit mode and say so.
*/
const beginEditingMessage = (message: EditingMessageState): boolean => {
if (editSendPending) return false;
const precondition = storeRaw.captureHistoryEditPrecondition(workspaceId, message.id);
if (!precondition) {
publishChatError(workspaceId, EDIT_NOT_HELD_MESSAGE);
return false;
}
setEditingMessage({ ...message, precondition });
return true;
};
// Cancelling a compaction edits its request row; the fence is captured before the interrupt,
// so rows the interruption settles inside the range surface as an explicit conflict.
const startEditingMessage = (message: EditingMessageState) => {
beginEditingMessage(message);
};

// Transcript-only workspaces swap the composer for a read-only notice, so clear any
// stale edit state instead of leaving the transcript stuck at an edit cutoff.
useEffect(() => {
if (transcriptOnly && editingMessage) {
setEditingState({ workspaceId, message: undefined });
}
}, [editingMessage, transcriptOnly, workspaceId]);

// A navigation (prompt arrows, ArrowUp edit) targets a row by historyId. The tail-first
// reveal may not have mounted it yet, so the scroll runs from an effect once it is in the
// DOM instead of a one-shot requestAnimationFrame that would find nothing.
const [pendingScrollTarget, setPendingScrollTarget] = useState<{
workspaceId: string;
historyId: string;
} | null>(null);

// Extract state from workspace state

// Keep a ref to the latest workspace state so event handlers (passed to memoized children)
// can stay referentially stable during streaming while still reading fresh data.
const workspaceStateRef = useRef(workspaceState);
useEffect(() => {
workspaceStateRef.current = workspaceState;
}, [workspaceState]);
const {
messages,
canInterrupt,
isCompacting,
isStreamStarting,
loading,
isHydratingTranscript,
isTranscriptCaughtUp,
transcriptReplayFailed,
hasOlderHistory,
loadingOlderHistory,
activeBashMonitorCount,
} = workspaceState;
const shouldShowPinnedTodoList = workspaceState.todos.length > 0;
const shouldShowReviewsBanner = reviews.reviews.length > 0;
const loadOlderMessagesShortcutLabel = formatKeybind(KEYBINDS.LOAD_OLDER_MESSAGES);

const {
warning: contextSwitchWarning,
handleModelChange,
handleCompact: handleContextSwitchCompact,
handleDismiss: handleContextSwitchDismiss,
} = useContextSwitchWarning({
workspaceId,
messages,
pendingModel,
use1M,
workspaceUsage,
api: api ?? undefined,
pendingSendOptions,
providersConfig,
});

// Apply message transformations:
// 1. Merge consecutive identical stream errors
// (bash_output grouping is done at render-time, not as a transformation)
// Use useDeferredValue to allow React to defer the heavy message list rendering
// during rapid updates (streaming), keeping the UI responsive.
// Must be defined before any early returns to satisfy React Hooks rules.
const transformedMessages = useMemo(() => mergeConsecutiveStreamErrors(messages), [messages]);
const immediateMessageSnapshot = useMemo(
() => ({ workspaceId, messages: transformedMessages }),
[workspaceId, transformedMessages]
);
const deferredMessageSnapshot = useDeferredValue(immediateMessageSnapshot);

// CRITICAL: Show immediate messages when streaming or when message count changes.
// useDeferredValue can defer indefinitely if React keeps getting new work (rapid deltas).
// During active streaming (reasoning, text), we MUST show immediate updates or the UI
// appears frozen while only the token counter updates (reads aggregator directly).
// Also bypass the deferred snapshot when it still belongs to the previous workspace so
// chat switches cannot briefly render stale transcript rows from the old workspace.
const shouldBypassDeferral = shouldBypassDeferredMessages(
immediateMessageSnapshot.messages,
deferredMessageSnapshot.messages,
{
immediateWorkspaceId: workspaceId,
deferredWorkspaceId: deferredMessageSnapshot.workspaceId,
}
);
const deferredMessages = shouldBypassDeferral
? immediateMessageSnapshot.messages
: deferredMessageSnapshot.messages;

const latestMessageId = getLastNonDecorativeMessage(deferredMessages)?.id ?? null;
const messageListContextValue = useMemo(
() => ({
workspaceId,
latestMessageId,
openTerminal: onOpenTerminal,
}),
[workspaceId, latestMessageId, onOpenTerminal]
);

const transcriptRowDerivations = useTranscriptRowDerivations({
workspaceId,
messages: deferredMessages,
});
const { bashOutputGroupInfos, expandedBashGroups, expandBashGroup } = transcriptRowDerivations;

const transcriptBundles = useTranscriptBundles({
workspaceId,
messages: deferredMessages,
transcriptDensity,
isTurnActive: isStreamStarting || canInterrupt,
});
const {
workBundleInfos,
operationalBundleInfos,
workBundleExpansionOverrides,
operationalBundleExpansionOverrides,
setWorkBundleExpanded,
setOperationalBundleExpanded,
} = transcriptBundles;

// Tail-first rendering: projections above are computed over the full array; only the
// mounted range starts at `revealFromIndex`. A cut is safe when the row is not inside a
// bundle or group (a head counts as safe), so bundles always mount whole.
const isSafeRevealCut = (index: number): boolean => {
const bashOutputGroup = bashOutputGroupInfos[index];
if (bashOutputGroup !== undefined && bashOutputGroup.position !== "first") return false;
const workBundle = workBundleInfos?.[index];
if (workBundle !== undefined && workBundle.position !== "head") return false;
const operationalBundle = operationalBundleInfos?.[index];
return operationalBundle === undefined || operationalBundle.position === "head";
};
// Keep rendering cached transcript rows during incremental (since) catch-up so workspace
// switches feel stable, even when they are known to be missing backend content: the server
// verified every row up to the cursor, and rows after it are swapped for the server's
// copies at caught-up, so the missing content mostly appends. Stale rows
// under a full replay hide behind the skeleton instead. The stream/monitor barrier
// lives in the composer dock, so it never vetoes the skeleton. The skeleton
// additionally holds until decoration data sources are known so the transcript and all
// composer decorations reveal in ONE commit — see useChatViewDataReady for the contract.
const { showHydrationPlaceholder: showTranscriptHydrationPlaceholder, revealDecorations } =
computeChatViewReveal({
isHydratingTranscript,
chatViewDataReady,
hasRenderableMessages: deferredMessages.length > 0,
isTranscriptStale: workspaceState.isTranscriptStale,
isIncrementalCatchUp: workspaceState.isIncrementalCatchUp,
});
// Use auto-scroll hook for scroll management
const {
contentRef,
sentinelRef,
autoScroll,
disableAutoScroll,
jumpToBottom,
setTranscriptDetached,
handleScroll,
markUserScrollIntent,
handleScrollContainerWheel,
handleScrollContainerMouseDown,
handleScrollContainerMouseMove,
handleScrollContainerMouseUp,
handleScrollContainerKeyDown,
} = useAutoScroll();

// While the skeleton owns the pane no row is mounted, so the reveal must not advance behind
// it: it would otherwise mount the whole transcript in the one commit that replaces the
// skeleton. Handing it no rows keeps it idle; the real transcript then starts tail-first.
const {
fromIndex: revealFromIndex,
toIndex: revealToIndex,
isDetached: isTranscriptDetached,
isFullyRevealed,
isRevealPaused,
revealMore,
revealThrough,
revealNewer,
resetToLatest,
} = useBoundedTranscriptReveal({
workspaceId,
messages: showTranscriptHydrationPlaceholder ? EMPTY_TRANSCRIPT : deferredMessages,
isSafeCut: isSafeRevealCut,
rowWeight: (index) => estimateTranscriptRowWeight(deferredMessages[index]),
// Bounds renderer heap on long read-backs (#4961 F20): past the cap the newest rows unmount
// ("detached") until Show newer or Jump to bottom brings them back.
maxMountedRows: TRANSCRIPT_MAX_MOUNTED_ROWS,
// A reader scrolled up keeps the rows they read: appends past the cap detach the slice instead.
followsTail: autoScroll,
// Read once, on the render where an edit deletes a detached slice's edge row: the DOM still
// shows the previous commit, so these are the rows the reader saw (refitDetached keeps them).
readVisibleMessageIds: () => visibleMessageIds(contentRef.current, deferredMessages),
});
useLayoutEffect(() => {
setTranscriptDetached(isTranscriptDetached);
}, [isTranscriptDetached, setTranscriptDetached]);
const revealedMessages = mountedSlice(deferredMessages, revealFromIndex, revealToIndex);
// An accepted non-blocking answer closes its question without any transcript change.
useAcceptedAnswersVersion();
// Open ask_user_question forms live in the composer dock (sticky to the bottom of the chat) and
// their transcript rows render as stubs.
const askUserQuestionPlacement = getTranscriptAskUserQuestionPlacement({
workspaceId,
transcriptOnly,
transcripts: [transformedMessages, deferredMessages],
});
const hasCommittedTranscriptRow =
!showTranscriptHydrationPlaceholder && revealedMessages.length > 0;
// Chat-switch User Timing (#4504): first transcript row painted after a switch. The commit
// before the store activates the workspace can hold cached rows that a synchronous re-render
// replaces with the skeleton before paint; the next-frame check drops those.
useEffect(() => {
if (!hasCommittedTranscriptRow) return;
return markChatSwitchMilestoneOnNextFrame(workspaceId, "first-row");
}, [workspaceId, hasCommittedTranscriptRow]);
// Settled: every row is mounted, or the automatic reveal paused at its rows budget (#4869;
// older rows then mount only on request) — either way no chunk is still mounting.
const revealSettled = isFullyRevealed || isRevealPaused;
const chatViewLoaded = !loading && !isHydratingTranscript && chatViewDataReady && revealSettled;
useWorkspaceViewHandoff(workspaceId, chatViewLoaded, transcriptReplayFailed); // T3 #5971
// Streaming rows render synchronously while older chunks still mount (see
// TranscriptBackfillContext). Gated on an active stream so a switch to an idle chat never
// flips the value, which would re-render every mounted markdown row once the reveal ends.
const isTranscriptBackfillingDuringStream = canInterrupt && !revealSettled;
// A paused reveal offers its unmounted rows first. Older server pages prepend above rows the
// reveal has not reached yet; offer them once it has.
const shouldRenderLoadOlderMessagesButton =
(isRevealPaused || (hasOlderHistory && isFullyRevealed)) && !isPixelSnapshotEnvironment();
// The server-page loading state never applies to mounting already-loaded rows.
const isLoadingOlderHistoryPage = loadingOlderHistory && !isRevealPaused;

// Rollover mode evaluates the clamped threshold, so the chat-input bar's visibility and
// text must use the same effective value the slider label advertises.
const autoCompactionResult = useMemo(() => {
const effectiveAutoCompactionThreshold = getEffectiveThreshold({
threshold: autoCompactionThreshold,
rolloverEnabled,
modelContextLimit: pendingModel
? getEffectiveContextLimit(pendingModel, use1M, providersConfig)
: null,
});
return checkAutoCompaction(
workspaceUsage,
pendingModel,
use1M,
effectiveAutoCompactionThreshold / 100,
undefined,
providersConfig
);
}, [
workspaceUsage,
pendingModel,
use1M,
providersConfig,
autoCompactionThreshold,
rolloverEnabled,
]);

// Show warning when: shouldShowWarning flag is true AND not currently compacting.
// Context-switch warning takes priority so we don't show competing banners.
const shouldShowCompactionWarning =
!isCompacting && autoCompactionResult.shouldShowWarning && !contextSwitchWarning;

// Vim mode state - needed for keybind selection (Ctrl+C in vim, Esc otherwise)
const vimEnabled = useUserPreferences(
(preferences) => preferences.appearance?.vimEnabled === true
);

const [pendingTimelineReveal, setPendingTimelineReveal] = useState<
CustomEventPayloads[typeof CUSTOM_EVENTS.REVEAL_TIMELINE_ANCHOR] | null
>(null);
const highlightedTimelineElementRef = useRef<HTMLElement | null>(null);

useEffect(() => {
const handleTimelineReveal = (event: Event) => {
const customEvent = event as CustomEventType<typeof CUSTOM_EVENTS.REVEAL_TIMELINE_ANCHOR>;
if (customEvent.detail.workspaceId !== workspaceId) {
return;
}
disableAutoScroll();
setPendingTimelineReveal(customEvent.detail);
};

window.addEventListener(CUSTOM_EVENTS.REVEAL_TIMELINE_ANCHOR, handleTimelineReveal);
return () => {
window.removeEventListener(CUSTOM_EVENTS.REVEAL_TIMELINE_ANCHOR, handleTimelineReveal);
};
}, [disableAutoScroll, workspaceId]);

// Store history loads can finish before the deferred transcript renders.
// Keep the reveal pending while its collapsed bundles expand.
useLayoutEffect(() => {
if (!pendingTimelineReveal) {
return;
}
// Like pendingScrollTarget: the tail being pinned again supersedes a reveal still waiting.
if (pendingTimelineReveal.workspaceId !== workspaceId || autoScroll) {
setPendingTimelineReveal(null);
return;
}

const targetIndex = findTimelineRevealMessageIndex(deferredMessages, pendingTimelineReveal);
if (targetIndex < 0) {
return;
}

const bashOutputGroup = bashOutputGroupInfos[targetIndex];
const bashGroupKey = bashOutputGroup?.groupKey;
if (bashGroupKey && !expandedBashGroups.has(bashGroupKey)) {
expandBashGroup(bashGroupKey);
return;
}

const workBundle = workBundleInfos?.[targetIndex];
if (workBundle && workBundleExpansionOverrides.get(workBundle.key) !== true) {
setWorkBundleExpanded(workBundle.key, true);
return;
}

const operationalBundle = operationalBundleInfos?.[targetIndex];
if (
operationalBundle &&
operationalBundleExpansionOverrides.get(operationalBundle.key) !== true
) {
setOperationalBundleExpanded(operationalBundle.key, true);
return;
}

// The tail-first reveal mounts older rows in chunks; re-run once the target's chunk lands.
// The automatic reveal may have paused above the target, so ask it to reach the target. A
// target below a detached slice re-centers the slice on it.
if (!isInSlice(targetIndex, revealFromIndex, revealToIndex)) {
revealThrough(targetIndex);
return;
}

const scrollContainer = contentRef.current;
const targetElement = scrollContainer
? findTranscriptRevealElement(scrollContainer, pendingTimelineReveal)
: undefined;
if (!targetElement) {
return;
}

// The highlight marks where the reveal landed and stays until the next reveal replaces it or the
// pane unmounts. Dismissing it on a timer would coordinate DOM state outside React and can
// overrun whenever a backgrounded tab throttles timers.
highlightedTimelineElementRef.current?.classList.remove(TIMELINE_REVEAL_HIGHLIGHT_CLASS);
highlightedTimelineElementRef.current = targetElement;
targetElement.classList.add(TIMELINE_REVEAL_HIGHLIGHT_CLASS);
targetElement.scrollIntoView({ behavior: "smooth", block: "center" });
setPendingTimelineReveal(null);
}, [
autoScroll,
bashOutputGroupInfos,
contentRef,
deferredMessages,
expandBashGroup,
expandedBashGroups,
operationalBundleExpansionOverrides,
operationalBundleInfos,
pendingTimelineReveal,
revealFromIndex,
revealThrough,
revealToIndex,
setOperationalBundleExpanded,
setWorkBundleExpanded,
workBundleExpansionOverrides,
workBundleInfos,
workspaceId,
]);

useEffect(() => {
return () => {
highlightedTimelineElementRef.current?.classList.remove(TIMELINE_REVEAL_HIGHLIGHT_CLASS);
highlightedTimelineElementRef.current = null;
};
}, [workspaceId]);

// The composer dock lives inside the scrollport (sticky to its bottom), so
// mousedown/keydown events from the composer bubble to the transcript
// handlers. Typing or clicking in the composer is not transcript scroll
// intent. Wheel/touch are intentionally not filtered because native scroll
// chaining can move the transcript.
const composerDockRef = useRef<HTMLDivElement>(null);
const isComposerDockEvent = useCallback((target: EventTarget | null): boolean => {
return target instanceof Node && (composerDockRef.current?.contains(target) ?? false);
}, []);

const handleTranscriptWheel = handleScrollContainerWheel;

const handleTranscriptMouseDown = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (isComposerDockEvent(event.target)) {
return;
}
handleScrollContainerMouseDown(event);
},
[handleScrollContainerMouseDown, isComposerDockEvent]
);

const handleTranscriptTouchMove = markUserScrollIntent;

const handleTranscriptKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (isComposerDockEvent(event.target)) {
return;
}
handleScrollContainerKeyDown(event);
},
[handleScrollContainerKeyDown, isComposerDockEvent]
);

const handleJumpToBottom = useCallback(
() => jumpToLiveTail(isTranscriptDetached, resetToLatest, jumpToBottom),
[isTranscriptDetached, resetToLatest, jumpToBottom]
);

// Handler to navigate (scroll) to a specific message by historyId
const handleNavigateToMessage = useCallback(
(historyId: string) => {
// Disable auto-scroll so the navigation isn't undone by streaming content
disableAutoScroll();
setPendingScrollTarget({ workspaceId, historyId });
},
[disableAutoScroll, workspaceId]
);

useEffect(() => {
if (pendingScrollTarget === null) return;
// Navigation disables auto-scroll first; the tail being pinned again (jump to bottom, a
// send, dismissing an edit, scrolling back down) supersedes a navigation still waiting
// for its row to mount, so the chunk mounting later cannot scroll away from the tail.
if (pendingScrollTarget.workspaceId !== workspaceId || autoScroll) {
setPendingScrollTarget(null);
return;
}
const scrollContainer = contentRef.current;
const target = scrollContainer
? findTranscriptMessageElement(scrollContainer, pendingScrollTarget.historyId)
: undefined;
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "center" });
setPendingScrollTarget(null);
return;
}
// Not in the DOM: keep waiting only while the row exists below the reveal boundary, and
// ask the reveal to reach it (the automatic reveal may have paused above it). A row that
// is gone, or eligible but hidden inside a collapsed bundle, is dropped (the pre-reveal
// behavior for an unmounted target was a silent no-op too).
const targetIndex = deferredMessages.findIndex(
(message) => "historyId" in message && message.historyId === pendingScrollTarget.historyId
);
if (targetIndex === -1 || isInSlice(targetIndex, revealFromIndex, revealToIndex)) {
setPendingScrollTarget(null);
return;
}
revealThrough(targetIndex);
}, [
autoScroll,
contentRef,
deferredMessages,
pendingScrollTarget,
revealFromIndex,
revealThrough,
revealToIndex,
workspaceId,
]);

const userMessageNavigationByHistoryId = useUserMessageNavigation({
messages: deferredMessages,
onNavigateToMessage: handleNavigateToMessage,
});

// ChatInput API for focus management
const chatInputAPI = useRef<ChatInputAPI | null>(null);

const handleQuoteText = useCallback((quotedText: string) => {
chatInputAPI.current?.appendText(quotedText);
chatInputAPI.current?.focus();
}, []);

// Right-clicking transcript text offers quick quote/copy actions,
// using selection first and hovered text as a fallback when nothing is selected.
const transcriptContextMenu = useTranscriptContextMenu({
transcriptRootRef: contentRef,
onQuoteText: handleQuoteText,
hasInputTarget: !transcriptOnly,
});

// Workspace switches should not leak background bash errors into the newly selected chat.
useEffect(() => {
clearBackgroundBashError();
}, [clearBackgroundBashError, workspaceId]);

useEffect(() => {
setEditingState({ workspaceId, message: undefined });
setPendingTimelineReveal(null);
}, [workspaceId]);

const handleChatInputReady = (api: ChatInputAPI) => {
chatInputAPI.current = api;
props.onChatInputReady?.(api);
};

// Handler for review notes from Code Review tab - adds review (starts attached)
// Depend only on addReview (not whole reviews object) to keep callback stable
const { addReview, checkReview } = reviews;

const handleCheckReviews = useCallback(
(ids: string[]) => {
for (const id of ids) {
checkReview(id);
}
// These notes were just sent: persist them as checked now rather than after the store's
// flush debounce, so a crash right after the send cannot bring them back as attached.
getReviewStateStore()
.flush(workspaceId)
.catch((error: unknown) => {
console.warn("Failed to persist sent review notes; the store keeps retrying:", error);
});
},
[checkReview, workspaceId]
);
const handleReviewNote = useCallback(
(data: ReviewNoteData) => {
addReview(data);
// New reviews start with status "attached" so they appear in chat input immediately
},
[addReview]
);

// Handlers for editing messages
const handleEditUserMessage = (message: EditingMessageState) => {
// Rows hide their Edit affordance while hydrating; this covers a click racing catch-up
// being lost (workspace switch, reconnect) so the composer never enters edit mode
// against a transcript that is not a verified copy of history.
if (!isTranscriptMutationAllowed(workspaceId)) return;
beginEditingMessage(message);
};

const restoreQueuedDraft = useCallback(
async (queuedMessage: QueuedMessageData) => {
const inputApi = chatInputAPI.current;
if (!inputApi) return;

await api?.workspace.clearQueue({ workspaceId });
inputApi.restoreDraft(normalizeQueuedMessage(queuedMessage), {
retainedSendIds: queuedMessage.sendIds,
});
},
[api, workspaceId]
);

const handleEditQueuedMessage = async () => {
const queuedMessage = queuedMessageToEdit(workspaceState?.queuedMessage, editingMessage);
if (!queuedMessage) return;

await restoreQueuedDraft(queuedMessage);
};

const sendQueuedImmediatelyInFlightRef = useRef<string | null>(null);

// The backend can resolve the interrupt RPC before the queued-message-cleared
// event renders, so keep duplicate send-now attempts blocked until the queued
// message id changes or clears.
useEffect(() => {
const queuedMessageId = workspaceState?.queuedMessage?.id ?? null;
if (queuedMessageId !== sendQueuedImmediatelyInFlightRef.current) {
sendQueuedImmediatelyInFlightRef.current = null;
}
}, [workspaceState?.queuedMessage?.id]);

const clearQueuedActionError = () => setQueuedActionErrorState(null);
const handleQueuedActionError = (error: unknown) => {
const queuedMessageId = workspaceState?.queuedMessage?.id;
if (!queuedMessageId) return;
setQueuedActionErrorState({
workspaceId,
messageId: queuedMessageId,
error: error instanceof Error ? error.message : String(error),
});
};

// Handler for sending queued message immediately (interrupt + send)
const handleSendQueuedImmediately = async () => {
const queuedMessage = workspaceState?.queuedMessage;
if (
!api ||
!queuedMessage ||
!workspaceState.canInterrupt ||
sendQueuedImmediatelyInFlightRef.current === queuedMessage.id
) {
return;
}

clearQueuedActionError();
sendQueuedImmediatelyInFlightRef.current = queuedMessage.id;
// Release the duplicate-send guard only if it still points at this attempt; a
// newer queued message (or a clear) may have already reset it in the meantime.
const clearInFlightGuardIfCurrent = () => {
if (sendQueuedImmediatelyInFlightRef.current === queuedMessage.id) {
sendQueuedImmediatelyInFlightRef.current = null;
}
};
const interruptResult = await runWithCatch(
() => {
// Set "interrupting" state immediately so UI shows "interrupting..." without flash.
storeRaw.setInterrupting(workspaceId);
return api.workspace.interruptStream({
workspaceId,
options: { sendQueuedImmediately: true },
});
},
(error) => {
clearInFlightGuardIfCurrent();
throw error;
}
);
if (!interruptResult.success) {
clearInFlightGuardIfCurrent();
throw new Error(interruptResult.error);
}
};

const handleQueuedDispatchModeChange = async (queueDispatchMode: QueueDispatchMode) => {
clearQueuedActionError();
if (!liveApi) {
throw new Error("Workspace API is unavailable.");
}
const result = await liveApi.workspace.setQueuedMessageDispatchMode({
workspaceId,
queueDispatchMode,
});
if (!result.success) {
throw new Error(result.error);
}
if (!result.data) {
throw new Error("The queued message is no longer available.");
}
};

const handleCancelCompactionFromBarrier = () => {
if (!api || !aggregator) {
return;
}

void cancelCompaction(api, workspaceId, aggregator, startEditingMessage);
};

const handleEditLastUserMessage = async () => {
if (transcriptOnly) return;

const current = workspaceStateRef.current;
if (!current) return;

if (current.queuedMessage) {
// A queued artifact send is the artifact's message, not a draft (its card has no Edit):
// restoring it would cancel the send and put its model-facing payload in the composer.
if (current.queuedMessage.artifactInteraction != null) return;
await restoreQueuedDraft(current.queuedMessage);
return;
}

// Otherwise, edit last user message. `current.messages` may be a cached/provisional view
// while the replay is still running, so ArrowUp must not enter edit mode until caught up.
if (!isTranscriptMutationAllowed(workspaceId)) return;
const transformedMessages = mergeConsecutiveStreamErrors(current.messages);
const lastUserMessage = [...transformedMessages]
.reverse()
.find(
(msg): msg is Extract<DisplayedMessage, { type: "user" }> =>
msg.type === "user" && canEditDisplayedUserMessage(msg)
);

if (!lastUserMessage) {
return;
}

if (!beginEditingMessage(buildEditingStateFromDisplayed(lastUserMessage))) return;
disableAutoScroll(); // Show jump-to-bottom indicator

// Scroll to the message being edited once it is mounted (see pendingScrollTarget).
setPendingScrollTarget({ workspaceId, historyId: lastUserMessage.historyId });
};

const handleEditLastUserMessageClick = () => {
void handleEditLastUserMessage();
};

const handleCancelEdit = useCallback(() => {
setEditingMessage(undefined);
// handleEditLastUserMessage disabled auto-scroll to keep the view centered
// on the edited message. Dismissing the edit hands scroll ownership back to
// the transcript tail; without this the view stays scrolled up until the
// user manually returns to the bottom.
handleJumpToBottom();
}, [handleJumpToBottom, setEditingMessage]);

const handleMessageSendStarted = useCallback(() => {
// Re-arm and pin before the send request crosses the IPC boundary. Waiting for
// send success can be too late because the backend may not resolve until the
// stream has already produced rows, leaving the first deltas offscreen when the
// user had previously scrolled up.
handleJumpToBottom();
}, [handleJumpToBottom]);

const handleClearHistory = useCallback(
async (percentage = 1.0) => {
// Clearing acts on the transcript the user sees. The only caller is the /clear slash
// command, whose phase already turns thrown errors into a restore + error toast.
if (!isTranscriptMutationAllowed(workspaceId)) {
throw new Error(TRANSCRIPT_NOT_CAUGHT_UP_MESSAGE);
}
// Re-arm the tail before clearing so the empty/starting state owns the bottom.
handleJumpToBottom();

// Truncate history in backend
const result = await api?.workspace.truncateHistory({ workspaceId, percentage });
// A partial failure (history already deleted but durable cleanup —
// e.g. sandbox kernel invalidation — failed) carries the only warning
// that cleared state may reappear after a restart. Throw so callers
// (slash command, dialogs) surface it instead of reporting success.
if (result && !result.success) {
throw new Error(result.error);
}
},
[workspaceId, handleJumpToBottom, api]
);

const handleResetContext = useCallback(async (): Promise<"reset" | "noop"> => {
// Same contract as handleClearHistory: the /reset (soft clear) phase surfaces the throw.
if (!isTranscriptMutationAllowed(workspaceId)) {
throw new Error(TRANSCRIPT_NOT_CAUGHT_UP_MESSAGE);
}
handleJumpToBottom();

const result = await api?.workspace.resetContext({ workspaceId });
if (!result?.success) {
throw new Error(result?.error ?? "Failed to reset context");
}
return result.data;
}, [workspaceId, handleJumpToBottom, api]);

const openInEditor = useOpenInEditor();
const handleOpenInEditor = useCallback(() => {
void openInEditor(workspaceId, namedWorkspacePath, runtimeConfig);
}, [workspaceId, namedWorkspacePath, openInEditor, runtimeConfig]);

// Intentionally no message/todo-driven auto-scroll effect here. Bottom pinning is
// owned by the scrollport/content ResizeObservers inside `useAutoScroll`, which
// pins viewport or content-size changes before paint. Calling `performAutoScroll`
// as a separate double-RAF on every delta used to race the RO pin, occasionally
// painting one frame at the wrong scrollTop (visible as a brief downward jitter).

const hasLoadedTranscriptRows = !workspaceState.loading && workspaceState.messages.length > 0;

// Reset transcript scroll ownership when switching workspaces. `jumpToBottom` both re-arms
// the ref-backed auto-scroll flag and pins any cached rows before paint; if rows are still
// hydrating, the next content resize owns the tail instead of showing the prior workspace's state.
// The stable `jumpToBottom`, not `handleJumpToBottom`: a switch restarts the reveal at the latest
// rows anyway, and an effect keyed on `handleJumpToBottom` would also re-run (and relock) on
// every render where its identity changes, or whenever the slice detaches (#6678 review).
useLayoutEffect(() => {
jumpToBottom();
}, [hasLoadedTranscriptRows, jumpToBottom, workspaceId]);

const shouldShowStreamingBarrier = isStreamStarting || canInterrupt;
// An armed background bash monitor means the turn ended but the agent will be
// woken on matching output. Keep the barrier mounted so StreamingBarrier can
// show its "waiting on monitor" state instead of the chat looking idle.
const shouldMountStreamingBarrier = shouldShowStreamingBarrier || activeBashMonitorCount > 0;
const showEmptyTranscriptPlaceholder =
deferredMessages.length === 0 &&
!showTranscriptHydrationPlaceholder &&
!shouldMountStreamingBarrier;
// Compute retry/interrupted chrome once for both keybinds and UI. Interruption and the retry
// candidate come from the latest rows; dividers are placed on the rendered (deferred) rows.
const {
lastRetryCandidateMessage,
shouldMountRetryBarrier,
showRetryBarrierUI,
interruptedBarrierMessageIds,
interruptedTailResumable,
} = getRetryBarrierDerivation({
messages: workspaceState.messages,
renderedMessages: deferredMessages,
pendingStreamStartTime: workspaceState.pendingStreamStartTime,
runtimeStatus: workspaceState.runtimeStatus,
lastAbortReason: workspaceState.lastAbortReason,
autoRetryStatus: workspaceState.autoRetryStatus,
isHydratingTranscript,
isTurnActive: shouldShowStreamingBarrier,
transcriptOnly,
});

// Owned here so the click and keybind paths share one resume/error. resetKey is
// the resume target, so error/spinner reset when the interrupted turn changes.
const { resume: resumeInterruptedStreamAsync, error: resumeInterruptedError } = useResumeStream(
workspaceId,
lastRetryCandidateMessage?.id,
workspaceState.messages
);
const resumeInterruptedStream = () => void resumeInterruptedStreamAsync();
// Live status belongs beside the input, below async decorations: neither history
// reveal nor banners arriving should bump the label and Stop across the screen.
const turnStatus = shouldMountStreamingBarrier ? (
<ChatDockSurface>
<StreamingBarrier
workspaceId={workspaceId}
vimEnabled={vimEnabled}
onCancelCompaction={handleCancelCompactionFromBarrier}
/>
</ChatDockSurface>
) : null;
const transcriptTailItems: TranscriptTailStackItem[] = [];
if (shouldMountRetryBarrier) {
transcriptTailItems.push(
createTranscriptTailStackItem({
key: "retry-barrier",
node: <RetryBarrier workspaceId={workspaceId} visible={showRetryBarrierUI} />,
})
);
}
if (shouldShowQueuedAgentTaskPrompt) {
transcriptTailItems.push(
createTranscriptTailStackItem({
key: "queued-agent-prompt",
node: (
<div className="mt-4 mb-1 ml-auto w-fit max-w-full">
<div className="rounded-lg border border-[var(--color-user-border)] bg-[var(--color-user-surface)] px-3 py-2 text-sm">
<div className="text-muted mb-1 text-[11px] font-medium">
{preStreamAgentTaskLabel}
</div>
<MarkdownRenderer
content={queuedAgentTaskPrompt ?? ""}
className="user-message-markdown text-foreground"
preserveLineBreaks
style={{ overflowWrap: "break-word", wordBreak: "break-word" }}
/>
</div>
</div>
),
})
);
}
// Rows a paused reveal mounts on request land above the reading position (#4869). Native
// scroll anchoring keeps that position only while scrollTop > 0, and the Load-older button
// sits at scrollTop 0, so the first mounted row's viewport offset is pinned explicitly from
// the request until the reveal settles again. Offsets are viewport-relative and measured after
// layout, so a native anchoring adjustment is never applied twice.
const loadOlderAnchorRef = useRef<{
element: HTMLElement;
top: number;
/** scrollTop after the last correction; a scroll away from it moves `top` (see below). */
scrollTop: number;
} | null>(null);
useLayoutEffect(() => {
const anchor = loadOlderAnchorRef.current;
const scrollContainer = contentRef.current;
if (anchor === null || scrollContainer === null) return;
if (anchor.element.isConnected) {
const delta = anchor.element.getBoundingClientRect().top - anchor.top;
if (delta !== 0) scrollContainer.scrollTop += delta;
anchor.scrollTop = scrollContainer.scrollTop;
}
if (revealSettled || !anchor.element.isConnected) loadOlderAnchorRef.current = null;
}, [contentRef, revealFromIndex, revealSettled, shouldRenderLoadOlderMessagesButton]);
// Show newer unmounts rows above the reader. Chromium's native scroll anchoring holds the read row
// (DetachedPastCap); the explicit pin of the last mounted row does not depend on it.
const handleShowNewerMessages = () => {
releaseBottomLock({ autoScroll, disableAutoScroll });
loadOlderAnchorRef.current = lastRowAnchor(contentRef.current);
revealNewer();
};
const handleTranscriptScroll = (event: React.UIEvent<HTMLDivElement>) => {
handleScroll(event);
// The reader scrolling while the requested rows mount moves the position to keep, so the
// next correction never undoes it. Shifted by the scroll distance rather than re-measured,
// so a correction's sub-pixel rounding never accumulates across chunks.
const anchor = loadOlderAnchorRef.current;
if (anchor !== null) {
const scrollTop = event.currentTarget.scrollTop;
anchor.top -= scrollTop - anchor.scrollTop;
anchor.scrollTop = scrollTop;
}
};

const handleLoadOlderHistory = useCallback(() => {
if (!shouldRenderLoadOlderMessagesButton) {
return;
}
// Already-loaded rows above a paused reveal mount before any older server page is fetched.
// Requests made while those rows mount are ignored (the button is gone until the reveal
// pauses again), like presses while a server page loads.
if (isRevealPaused) {
// While locked to the bottom the sentinel keeps the tail in place instead.
const firstRow = autoScroll
? null
: contentRef.current?.querySelector<HTMLElement>("[data-message-id]");
loadOlderAnchorRef.current =
firstRow && contentRef.current
? {
element: firstRow,
top: firstRow.getBoundingClientRect().top,
scrollTop: contentRef.current.scrollTop,
}
: null;
revealMore();
return;
}
if (loadingOlderHistory) {
return;
}

storeRaw.loadOlderHistory(workspaceId).catch((error) => {
console.warn(`[ChatPane] Failed to load older history for ${workspaceId}:`, error);
});
}, [
autoScroll,
contentRef,
isRevealPaused,
loadingOlderHistory,
revealMore,
shouldRenderLoadOlderMessagesButton,
storeRaw,
workspaceId,
]);

// Handle keyboard shortcuts (using optional refs that are safe even if not initialized)
useAIViewKeybinds({
workspaceId,
// Allow interrupt keybind even while waiting for stream-start ("starting...").
canInterrupt:
(workspaceState?.canInterrupt ?? false) || (workspaceState?.isStreamStarting ?? false),
// The visible barrier, not the raw interruption: a context_exceeded error hides the barrier,
// and Esc must not send a Stop (persisting an auto-retry opt-out) for nothing visible (#5111).
showRetryBarrier: showRetryBarrierUI,
chatInputAPI,
jumpToBottom: handleJumpToBottom,
loadOlderHistory: shouldRenderLoadOlderMessagesButton ? handleLoadOlderHistory : null,
showNewerMessages: isTranscriptDetached ? handleShowNewerMessages : null,
handleOpenTerminal: onOpenTerminal,
handleOpenInEditor,
aggregator,
setEditingMessage: startEditingMessage,
vimEnabled,
canResumeInterruptedStream: interruptedTailResumable,
resumeInterruptedStream,
});

// Clear editing state if the message being edited no longer exists
// Must be before early return to satisfy React Hooks rules
useEffect(() => {
if (!workspaceState || !editingMessage) return;
// Conflict recovery re-reads the transcript (a full replay empties the aggregator first,
// a pre-window range discards cached pages); the refresh outcome decides whether the
// edited row is gone, not the transient absence of its row.
if (editingMessage.preconditionInvalidated === true) return;

const transformedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
const editCutoffHistoryId = transformedMessages.find(
(
msg
): msg is Exclude<
DisplayedMessage,
{ type: "history-hidden" | "workspace-init" | "compaction-boundary" }
> =>
msg.type !== "history-hidden" &&
msg.type !== "workspace-init" &&
msg.type !== "compaction-boundary" &&
msg.historyId === editingMessage.id
)?.historyId;

if (!editCutoffHistoryId) {
// Message was replaced or deleted - clear editing state
setEditingMessage(undefined);
}
}, [workspaceState, editingMessage, setEditingMessage]);

// When editing, find the cutoff point
const editCutoffHistoryId = editingMessage
? transformedMessages.find(
(
msg
): msg is Exclude<
DisplayedMessage,
{ type: "history-hidden" | "workspace-init" | "compaction-boundary" }
> =>
msg.type !== "history-hidden" &&
msg.type !== "workspace-init" &&
msg.type !== "compaction-boundary" &&
msg.historyId === editingMessage.id
)?.historyId
: undefined;

// Find the ID of the latest propose_plan tool call for external edit detection
// Only the latest plan should fetch fresh content from disk
let latestProposePlanId: string | null = null;
for (let i = transformedMessages.length - 1; i >= 0; i--) {
const msg = transformedMessages[i];
if (msg.type === "tool" && msg.toolName === "propose_plan") {
latestProposePlanId = msg.id;
break;
}
}

const renderMessageAtIndex = (
message: DisplayedMessage,
index: number,
options: { key: string; className?: string }
): React.ReactNode => {
const rowProps = getTranscriptRowProps({
derivations: transcriptRowDerivations,
userMessageNavigationByHistoryId,
message,
index,
});
const { bashOutputGroup, bashGroupKey } = rowProps;

if (rowProps.hidden) {
return null;
}

const isAtCutoff =
editCutoffHistoryId !== undefined &&
message.type !== "history-hidden" &&
message.type !== "workspace-init" &&
message.type !== "compaction-boundary" &&
message.historyId === editCutoffHistoryId;

const messageNode = (
<MessageRenderer
message={message}
// No onEdit → UserMessage omits its Edit button; provisional (not caught-up) rows
// must not offer an edit whose truncation point the backend may not agree with.
onEditUserMessage={
transcriptOnly || !isTranscriptCaughtUp ? undefined : handleEditUserMessage
}
workspaceId={workspaceId}
isCompacting={isCompacting}
editSendPending={editSendPending}
onReviewNote={handleReviewNote}
isLatestProposePlan={
message.type === "tool" &&
message.toolName === "propose_plan" &&
message.id === latestProposePlanId
}
bashOutputGroup={bashOutputGroup}
taskReportLinking={rowProps.taskReportLinking}
userMessageNavigation={rowProps.userMessageNavigation}
/>
);

return (
<React.Fragment key={options.key}>
{options.className ? <div className={options.className}>{messageNode}</div> : messageNode}
{bashOutputGroup?.position === "first" && bashGroupKey && (
<BashOutputCollapsedIndicator
processId={bashOutputGroup.processId}
collapsedCount={bashOutputGroup.collapsedCount}
isExpanded={rowProps.isBashGroupExpanded}
onToggle={() => transcriptRowDerivations.toggleBashOutputGroup(bashGroupKey)}
/>
)}
{isAtCutoff && <EditCutoffBarrier />}
{interruptedBarrierMessageIds.has(message.id) && (
<InterruptedBarrier
resumable={interruptedTailResumable && message.id === lastRetryCandidateMessage?.id}
onResume={resumeInterruptedStream}
error={resumeInterruptedError}
/>
)}
</React.Fragment>
);
};

return (
<>
<PerfRenderMarker id="chat-pane.transcript">
{/* Spacer for fixed mobile header - mobile-header-spacer adds padding-top on touch devices.
The composer dock is IN-FLOW scroll content (sticky to the scrollport
bottom), so this region — and therefore the scrollport's clientHeight —
never resizes when the composer grows or shrinks. */}
<div
className={cn(
"relative flex-1 overflow-hidden",
props.embedded !== true && "mobile-header-spacer"
)}
>
{/* Before the scrollport, which holds the composer dock, so Tab reaches the page
before the composer below it. */}
<LazyStatusView
<AgentInterfaceStatusView
key={workspaceId}
workspaceId={workspaceId}
view={props.agentInterfaceView}
dockRef={composerDockRef}
composerApiRef={chatInputAPI}
/>
<div
ref={contentRef}
onWheel={handleTranscriptWheel}
onMouseDown={handleTranscriptMouseDown}
onMouseMove={handleScrollContainerMouseMove}
onMouseUp={handleScrollContainerMouseUp}
onTouchMove={handleTranscriptTouchMove}
onKeyDown={handleTranscriptKeyDown}
onScroll={handleTranscriptScroll}
onContextMenu={transcriptContextMenu.onContextMenu}
tabIndex={transcriptTabIndex(showStatusView)}
data-testid="message-window"
// Settled marker for perf tests and story play helpers: includes
// decoration data readiness AND the tail-first reveal having settled
// (fully revealed or paused at the automatic budget), so waiting on it
// observes the chat view's final layout rather than a tail whose
// earlier chunks are still committing.
data-loaded={chatViewLoaded}
// Browser scroll anchoring stays ENABLED on the scrollport; the
// overflow-anchor policy lives on the inner content (opt rows out while
// locked so the bottom sentinel is the sole anchor). No bottom padding:
// clearance for the composer is the in-flow dock itself, so it is always
// exact and reserved in the same layout pass as any dock height change.
// The flex column makes the transcript content stretch (flex-1) so the
// dock sits at the scrollport bottom even when the transcript is short.
// The named `transcript` container is what the sticky plan TOC queries
// for visibility — using a container query rather than a viewport media
// query means sidebars opening/closing correctly hide the TOC even when
// the viewport width is unchanged. See `.plan-toc-aside` in globals.css.
// `--chat-reserved-inline-end`: room a layout floats over the chat's right edge (the
// collapsed right sidebar's overview card, see globals.css). Padding by it centers the
// column, and the composer under it, in the space left of the overlay, so the column
// sits as far from the overlay as from the left edge. The transcript container query
// measures the content box, so the plan TOC sees the narrower gutters too.
// `chat-reserved-inline-end-scrollport`: globals.css eases the padding with the
// sidebar's width slide.
className="chat-reserved-inline-end-scrollport @container/transcript flex h-full flex-col overflow-x-hidden overflow-y-auto pt-[15px] pr-[calc(15px+var(--chat-reserved-inline-end,0px))] pl-[15px] leading-[1.5] break-words whitespace-pre-wrap"
>
<div
// While locked, opt the whole transcript subtree out of scroll
// anchoring so the only anchor candidate is the sibling bottom
// sentinel below — native anchoring then pins the bottom on append.
style={autoScroll ? TRANSCRIPT_CONTENT_NO_ANCHOR_STYLE : undefined}
role="log"
// Live only once the historical reveal has settled: chunks of replayed history
// mounting during a stream would otherwise be announced as fresh output.
aria-live={canInterrupt && revealSettled ? "polite" : "off"}
aria-busy={canInterrupt || isHydratingTranscript}
aria-label="Conversation transcript"
className={cn(
// `plan-toc-aware` opts only the centered max-w transcript into the
// sticky plan TOC layout. In `chatTranscriptFullWidth` mode the plan
// already fills the available width, so the TOC would either
// overlap content or get clipped by `overflow-x-hidden`.
// `w-full` is required in the centered mode because auto cross-axis
// margins disable flex-item stretch.
chatTranscriptFullWidth ? "w-full" : "plan-toc-aware max-w-chat mx-auto w-full",
// Push the dock to the bottom for short transcripts without reserving
// loading space: cached replay overlays the dock edge, not transcript rows.
"flex-1",
// Only the empty/centered placeholder fills height (as a flex column
// so the placeholder's flex-1 centering works). The hydration
// skeleton renders in normal top-aligned transcript flow so it sits
// where real messages will, avoiding a jump when hydration completes.
showEmptyTranscriptPlaceholder && "flex flex-col",
// Under the status page: hidden but laid out, so its scroll position survives.
{ invisible: showStatusView }
)}
>
{showTranscriptHydrationPlaceholder ? (
<TranscriptHydrationSkeleton workspaceId={workspaceId} />
) : showEmptyTranscriptPlaceholder ? (
<div className="text-placeholder flex h-full flex-1 flex-col items-center justify-center text-center [&_h3]:m-0 [&_h3]:mb-2.5 [&_h3]:text-base [&_h3]:font-medium [&_p]:m-0 [&_p]:text-[13px]">
<h3>No Messages Yet</h3>
<p>Send a message below to begin</p>
{hasRepository && (
<p className="text-muted mt-5 flex items-start gap-2 text-xs">
<Lightbulb aria-hidden="true" className="mt-0.5 h-3 w-3 shrink-0" />
<span>
Tip: Add a{" "}
<code className="bg-inline-code-dark-bg text-code-string rounded-[3px] px-1.5 py-0.5 font-mono text-[11px]">
.xum/init
</code>{" "}
hook to your project to run setup commands
<br />
(e.g., install dependencies, build) when creating new workspaces
</span>
</p>
)}
</div>
) : (
<BashCollapsedSummaryModeProvider>
<MessageListProvider
value={messageListContextValue}
isTranscriptBackfilling={isTranscriptBackfillingDuringStream}
>
{shouldRenderLoadOlderMessagesButton && (
<div className="flex justify-center py-3">
<TooltipIfPresent
tooltip={`Load older messages (${loadOlderMessagesShortcutLabel})`}
side="top"
>
<button
type="button"
onClick={handleLoadOlderHistory}
disabled={isLoadingOlderHistoryPage}
className="text-muted hover:text-foreground text-xs underline underline-offset-2 transition-colors disabled:opacity-50"
>
{isLoadingOlderHistoryPage ? "Loading..." : "Load older messages"}
</button>
</TooltipIfPresent>
</div>
)}
<AskUserQuestionPlacementProvider value={askUserQuestionPlacement}>
<TranscriptBundleRows
workspaceId={workspaceId}
messages={revealedMessages}
indexOffset={revealFromIndex}
bundles={transcriptBundles}
renderMessageAtIndex={renderMessageAtIndex}
/>
</AskUserQuestionPlacementProvider>
<ShowNewerMessagesButton
visible={isTranscriptDetached}
onClick={handleShowNewerMessages}
/>
</MessageListProvider>
</BashCollapsedSummaryModeProvider>
)}
<TranscriptTailStackLane items={transcriptTailItems} />
</div>
{/* Bottom anchor: a 0-height sibling of the transcript content. While
locked it is the sole `overflow-anchor: auto` element, so native CSS
scroll anchoring keeps it (and therefore the bottom) pinned as rows
append above it. It sits between the transcript content and the
in-flow composer dock: appends grow content ABOVE it (anchoring
compensates), while dock growth happens BELOW it (covered by the
scrollport-children ResizeObserver in useAutoScroll, which re-pins
before paint). */}
<div
ref={sentinelRef}
data-testid="transcript-bottom-sentinel"
aria-hidden="true"
className="h-0 w-full"
style={TRANSCRIPT_BOTTOM_SENTINEL_STYLE}
/>
<PerfRenderMarker id="chat-pane.input">
{/* The composer dock is in-flow scroll content stuck to the scrollport
bottom: clearance for the last message is reserved by normal flow
layout in the same pass as any dock height change (no measured
--composer-h channel to lag a frame behind and tear), while
`sticky` keeps the composer visually pinned over the transcript
when the user scrolls up. `mx-[-15px]` cancels the scrollport's
gutter so the dock reaches the edges (short of any reserved overlay
room on the right); `whitespace-normal
break-normal` reset the transcript text inheritance. clientHeight
of the scrollport never changes with dock height (send-flash
invariant). `bg-surface-primary` keeps transcript content from
showing through gaps between decoration banners. */}
<ChatDockColumnProvider value={chatTranscriptFullWidth}>
<div
ref={composerDockRef}
data-testid="chat-composer-dock"
className="bg-surface-primary sticky bottom-0 z-10 mx-[-15px] break-normal whitespace-normal"
style={COMPOSER_DOCK_STYLE}
>
{/* An active turn does not mean history has loaded. Keep the dock shimmer
visible alongside turn controls whenever the full skeleton is absent. */}
{isHydratingTranscript && !showTranscriptHydrationPlaceholder && (
<div
role="status"
data-testid="transcript-loading-status"
className="pointer-events-none absolute inset-x-0 top-0 z-20 h-1 overflow-hidden"
>
<Skeleton
variant="shimmer"
className="bg-muted/30 block h-full w-full rounded-none"
/>
<span className="sr-only">Loading messages...</span>
</div>
)}
{offersJumpToBottom(autoScroll, isTranscriptDetached, showStatusView) && (
<button
onClick={handleJumpToBottom}
type="button"
// Sit just above the composer dock (8px gap), tracking its live
// height through normal layout instead of a measured offset.
className="assistant-chip font-primary text-foreground hover:assistant-chip-hover absolute bottom-full left-1/2 z-20 mb-2 -translate-x-1/2 cursor-pointer rounded-[20px] px-2 py-1 text-xs font-medium shadow-[0_4px_12px_rgba(0,0,0,0.3)] backdrop-blur-[1px] transition-transform duration-200 hover:scale-105 active:scale-95"
>
Jump to bottom{" "}
<span className="mobile-hide-shortcut-hints">
({formatKeybind(KEYBINDS.JUMP_TO_BOTTOM)})
</span>
</button>
)}
{transcriptOnly ? (
// Transcript-only workspaces keep their historical transcript, but the whole
// composer surface is replaced with a single read-only notice.
<>
{turnStatus}
{/* A failed cleanup can leave a flagged row whose checkout is gone (#4983). */}
{meta?.delegatedCreationInterrupted === true && (
<DelegatedCreationInterruptedBanner
key={workspaceId}
workspaceId={workspaceId}
workspaceName={workspaceName}
/>
)}
<TranscriptOnlyNoticePane
workspaceId={workspaceId}
heldInputs={workspaceState?.heldInputs ?? NO_HELD_INPUTS}
/>
</>
) : (
<ChatInputPane
turnStatus={turnStatus}
kind={meta?.kind}
workspaceId={workspaceId}
projectName={projectName}
workspaceName={workspaceName}
revealDecorations={revealDecorations}
isStreamStarting={isStreamStarting}
isTranscriptCaughtUp={isTranscriptCaughtUp}
transcriptReplayFailed={transcriptReplayFailed}
runtimeConfig={runtimeConfig}
isPreStreamAgentTask={isPreStreamAgentTask}
delegatedCreationInterrupted={meta?.delegatedCreationInterrupted === true}
preStreamAgentTaskStatus={
meta?.taskStatus === "starting" ? "starting" : "queued"
}
isCompacting={isCompacting}
shouldShowPinnedTodoList={shouldShowPinnedTodoList}
shouldShowReviewsBanner={shouldShowReviewsBanner}
canInterrupt={canInterrupt}
autoCompactionResult={autoCompactionResult}
shouldShowCompactionWarning={shouldShowCompactionWarning}
rolloverEnabled={rolloverEnabled}
contextSwitchWarning={contextSwitchWarning}
onContextSwitchCompact={handleContextSwitchCompact}
onContextSwitchDismiss={handleContextSwitchDismiss}
onModelChange={handleModelChange}
onMessageSendStarted={handleMessageSendStarted}
onMessageSent={handleJumpToBottom}
onResetContext={handleResetContext}
onTruncateHistory={handleClearHistory}
editingMessage={editingMessage}
onCancelEdit={handleCancelEdit}
onEditingMessageChange={updateEditingMessage}
onEditLastUserMessage={handleEditLastUserMessageClick}
onEditSendPendingChange={handleEditSendPendingChange}
onChatInputReady={handleChatInputReady}
queuedMessage={workspaceState?.queuedMessage ?? null}
heldInputs={workspaceState?.heldInputs ?? NO_HELD_INPUTS}
onEditQueuedMessage={() => void handleEditQueuedMessage()}
onSendQueuedImmediately={
workspaceState?.canInterrupt ? handleSendQueuedImmediately : undefined
}
onQueuedDispatchModeChange={handleQueuedDispatchModeChange}
onQueuedActionError={handleQueuedActionError}
queuedActionError={queuedActionError}
onClearQueuedActionError={clearQueuedActionError}
reviews={reviews}
onCheckReviews={handleCheckReviews}
/>
)}
</div>
</ChatDockColumnProvider>
</PerfRenderMarker>
</div>
{transcriptContextMenu.menu}
</div>
</PerfRenderMarker>
</>
);
};

Check warning on line 1941 in src/browser/components/ChatPane/ChatPane.tsx

View workflow job for this annotation

GitHub Actions / Quality / CRAP

CRAP crap-tax (warning)

src/browser/components/ChatPane/ChatPane.tsx:448-1941 ChatPaneContent: touched hotspot, CC 233 unchanged: consider shrinking it

const TranscriptOnlyNoticePane: React.FC<{
workspaceId: string;
Expand Down
14 changes: 0 additions & 14 deletions src/browser/rendererFirstLoad.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,20 +87,6 @@ describe("renderer first load", () => {
);
}, 60_000);

// #7510: ChatPane always rendered the Agent interface status view, so its static import pulled
// the artifact viewer and SandboxedArtifactFrame (~14 KB brotli) into the workspace page's
// download even with the experiment off.
test("the workspace page's static imports do not reach the artifact frame", async () => {
const closure = new Set([
...(await staticImportClosure("src/browser/main.tsx")),
...(await staticImportClosure("src/browser/components/AIView/AIView.tsx")),
]);
expect(closure.has("src/browser/components/ChatPane/ChatPane.tsx")).toBe(true);
expect(
closure.has("src/browser/features/RightSidebar/ArtifactsTab/SandboxedArtifactFrame.tsx")
).toBe(false);
}, 60_000);

test("the workspace page's static imports do not reach the workspace-menu modals", async () => {
// App.tsx starts the workspace page chunk (loadAIView() -> AIView) while the entry boots, so a
// phone downloads both closures before the composer works. WorkspaceMenuBar mounted these
Expand Down
Loading