Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0e0d9ae
feat: implement /btw side-question slash command
ammar-agent May 16, 2026
e20dc40
feat: placeholder tip carousel surfacing /btw + slash commands
ammar-agent May 16, 2026
50127bd
feat: side-question Q/A block + main-chat suspension + time-based tip…
ammar-agent May 16, 2026
7d857db
feat(/btw): split interrupted main-agent message around the side branch
ammar-agent May 16, 2026
cda0b5f
fix(/btw): keep side question at tail until answer settles
ammar-agent May 16, 2026
0ff1398
fix(/btw): hold scroll at side answer after replay
ammar-agent May 16, 2026
58f3348
fix(/btw): keep side questions out of provider context
ammar-agent May 16, 2026
9631695
fix(/btw): close failed side streams before deleting placeholder
ammar-agent May 16, 2026
612fbfd
refactor(/btw): tidy side-question plumbing
ammar-agent May 16, 2026
972927f
fix(/btw): address side question review findings
ammar-agent May 16, 2026
a432054
fix(/btw): keep side answers out of interrupt state
ammar-agent May 16, 2026
aaef62e
fix(/btw): preserve buffered main stream state
ammar-agent May 16, 2026
a952c44
fix(/btw): honor context resets in transcripts
ammar-agent May 16, 2026
1ffec9b
fix(/btw): retain main context after prior side questions
ammar-agent May 16, 2026
55344b7
fix(/btw): include live main stream text in context
ammar-agent May 16, 2026
e9533e5
fix(/btw): snapshot live stream parts before prompting
ammar-agent May 16, 2026
d2153e4
fix(/btw): capture stream snapshot before candidate lookup
ammar-agent May 16, 2026
2b7af8d
fix(/btw): dedupe live stream partial in transcript
ammar-agent May 16, 2026
1c86450
fix(/btw): keep side branch at interruption point while streaming
ammar-agent May 16, 2026
efb9d91
fix(/btw): preserve main stream while side question runs
ammar-agent May 16, 2026
2bcce4b
fix(/btw): hold scroll on side branch
ammar-agent May 16, 2026
105b178
fix(/btw): ignore replayed side streams
ammar-agent May 16, 2026
2f4cd4a
fix(/btw): link side answers to questions
ammar-agent May 16, 2026
599e39b
fix(/btw): keep side branch scroll anchored
ammar-agent May 16, 2026
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
189 changes: 167 additions & 22 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ import {
normalizeQueuedMessage,
type EditingMessageState,
} from "@/browser/utils/chatEditing";
import {
findActiveSideQuestionScrollHoldTarget,
findSideQuestionScrollHoldTarget,
type SideQuestionScrollHoldState,
} from "./sideQuestionScrollHold";
import { recordSyntheticReactRenderSample } from "@/browser/utils/perf/reactProfileCollector";

// Perf e2e runs load the production bundle where React's onRender profiler callbacks may not
Expand Down Expand Up @@ -155,6 +160,15 @@ type ReviewsState = ReturnType<typeof useReviews>;

const AUTO_SCROLL_TRANSCRIPT_STYLE = { overflowAnchor: "none" } as const;

function findTranscriptMessageElement(
scrollContainer: HTMLElement,
historyId: string
): HTMLElement | undefined {
return Array.from(scrollContainer.querySelectorAll<HTMLElement>("[data-message-id]")).find(
(element) => element.getAttribute("data-message-id") === historyId
);
}

export const ChatPane: React.FC<ChatPaneProps> = (props) => {
const {
workspaceId,
Expand Down Expand Up @@ -395,14 +409,143 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
handleScrollContainerKeyDown,
} = useAutoScroll();

const sideQuestionScrollHoldRef = useRef<SideQuestionScrollHoldState>({
initialized: false,
heldSideQuestionIds: new Set<string>(),
previouslyStreamingSideAnswerIds: new Set<string>(),
heldSideAnswerIds: new Set<string>(),
});

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

const clearActiveSideQuestionScrollHold = useCallback(() => {
activeSideQuestionScrollHoldTargetRef.current = null;
}, []);

useLayoutEffect(() => {
sideQuestionScrollHoldRef.current = {
initialized: false,
heldSideQuestionIds: new Set<string>(),
previouslyStreamingSideAnswerIds: new Set<string>(),
heldSideAnswerIds: new Set<string>(),
};
activeSideQuestionScrollHoldTargetRef.current = null;
}, [workspaceId]);

useLayoutEffect(() => {
const { nextState, targetHistoryId: detectedTargetHistoryId } =
findSideQuestionScrollHoldTarget(deferredMessages, sideQuestionScrollHoldRef.current);
sideQuestionScrollHoldRef.current = nextState;

const activeTargetHistoryId = activeSideQuestionScrollHoldTargetRef.current;
const activeHold = findActiveSideQuestionScrollHoldTarget(
deferredMessages,
activeTargetHistoryId
);
const continuingTargetHistoryId =
activeHold.targetHistoryId === activeTargetHistoryId ? activeHold.targetHistoryId : undefined;
const shouldStartHold = detectedTargetHistoryId !== undefined && autoScroll;
const targetHistoryId = shouldStartHold ? detectedTargetHistoryId : continuingTargetHistoryId;

if (!targetHistoryId) {
if (!activeHold.keepActive) {
activeSideQuestionScrollHoldTargetRef.current = null;
}
return;
}

const scrollContainer = contentRef.current;
if (!scrollContainer) {
return;
}

const alignSideBranchStart = (): void => {
findTranscriptMessageElement(scrollContainer, targetHistoryId)?.scrollIntoView({
block: "start",
inline: "nearest",
});
};

// The main stream can now keep rendering below an active /btw branch. Once
// that happens, bottom-lock would otherwise follow the main tail and yank
// the user away from the aside they just requested. Release bottom-lock once
// per side branch and keep the side-question row readable; Jump to bottom is
// then the explicit opt-in to resume watching the live tail. Keep re-aligning
// while the side answer grows because the first scroll may clamp at the old
// bottom before enough below-branch content exists to place the aside higher.
if (shouldStartHold) {
activeSideQuestionScrollHoldTargetRef.current = targetHistoryId;
disableAutoScroll();
}
alignSideBranchStart();

const currentHold = findActiveSideQuestionScrollHoldTarget(deferredMessages, targetHistoryId);
if (
!currentHold.keepActive &&
activeSideQuestionScrollHoldTargetRef.current === targetHistoryId
) {
activeSideQuestionScrollHoldTargetRef.current = null;
}

const win = typeof window !== "undefined" ? window : undefined;
const raf = win?.requestAnimationFrame?.bind(win);
const cancelRaf = win?.cancelAnimationFrame?.bind(win);
if (!raf || !cancelRaf) {
return;
}

const frameId = raf(alignSideBranchStart);
return () => cancelRaf(frameId);
}, [autoScroll, contentRef, deferredMessages, disableAutoScroll]);

const handleTranscriptWheel = useCallback(
(event: React.WheelEvent<HTMLDivElement>) => {
if (event.deltaX !== 0 || event.deltaY !== 0) {
clearActiveSideQuestionScrollHold();
}
handleScrollContainerWheel(event);
},
[clearActiveSideQuestionScrollHold, handleScrollContainerWheel]
);

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

const handleTranscriptTouchMove = useCallback(() => {
clearActiveSideQuestionScrollHold();
markUserScrollIntent();
}, [clearActiveSideQuestionScrollHold, markUserScrollIntent]);

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

const handleJumpToBottom = useCallback(() => {
clearActiveSideQuestionScrollHold();
jumpToBottom();
}, [clearActiveSideQuestionScrollHold, 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();
requestAnimationFrame(() => {
const element = contentRef.current?.querySelector(`[data-message-id="${historyId}"]`);
element?.scrollIntoView({ behavior: "smooth", block: "center" });
const scrollContainer = contentRef.current;
if (!scrollContainer) return;
findTranscriptMessageElement(scrollContainer, historyId)?.scrollIntoView({
behavior: "smooth",
block: "center",
});
});
},
[contentRef, disableAutoScroll]
Expand Down Expand Up @@ -559,10 +702,12 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {

// Scroll to the message being edited
requestAnimationFrame(() => {
const element = contentRef.current?.querySelector(
`[data-message-id="${lastUserMessage.historyId}"]`
);
element?.scrollIntoView({ behavior: "smooth", block: "center" });
const scrollContainer = contentRef.current;
if (!scrollContainer) return;
findTranscriptMessageElement(scrollContainer, lastUserMessage.historyId)?.scrollIntoView({
behavior: "smooth",
block: "center",
});
});
}, [restoreQueuedDraft, contentRef, disableAutoScroll, setEditingMessage, transcriptOnly]);

Expand All @@ -579,8 +724,8 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
// 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.
jumpToBottom();
}, [jumpToBottom]);
handleJumpToBottom();
}, [handleJumpToBottom]);

const handleMessageSent = useCallback(
(dispatchMode: QueueDispatchMode = "tool-end") => {
Expand All @@ -593,31 +738,31 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {

// Slash-command send paths still report after backend success; keep this
// harmless duplicate pin so those paths also re-arm auto-scroll.
jumpToBottom();
handleJumpToBottom();
},
[autoBackgroundOnSend, jumpToBottom]
[autoBackgroundOnSend, handleJumpToBottom]
);

const handleClearHistory = useCallback(
async (percentage = 1.0) => {
// Re-arm the tail before clearing so the empty/starting state owns the bottom.
jumpToBottom();
handleJumpToBottom();

// Truncate history in backend
await api?.workspace.truncateHistory({ workspaceId, percentage });
},
[workspaceId, jumpToBottom, api]
[workspaceId, handleJumpToBottom, api]
);

const handleResetContext = useCallback(async (): Promise<"reset" | "noop"> => {
jumpToBottom();
handleJumpToBottom();

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

const openInEditor = useOpenInEditor();
const handleOpenInEditor = useCallback(() => {
Expand All @@ -636,8 +781,8 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
// 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.
useLayoutEffect(() => {
jumpToBottom();
}, [hasLoadedTranscriptRows, jumpToBottom, workspaceId]);
handleJumpToBottom();
}, [hasLoadedTranscriptRows, handleJumpToBottom, workspaceId]);

// Compute showRetryBarrier once for both keybinds and UI.
// Track if last message was interrupted or errored (for RetryBarrier).
Expand Down Expand Up @@ -757,7 +902,7 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
(workspaceState?.canInterrupt ?? false) || (workspaceState?.isStreamStarting ?? false),
showRetryBarrier,
chatInputAPI,
jumpToBottom,
jumpToBottom: handleJumpToBottom,
loadOlderHistory: shouldRenderLoadOlderMessagesButton ? handleLoadOlderHistory : null,
handleOpenTerminal: onOpenTerminal,
handleOpenInEditor,
Expand Down Expand Up @@ -852,12 +997,12 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
<div className="mobile-header-spacer relative flex-1 overflow-hidden">
<div
ref={contentRef}
onWheel={handleScrollContainerWheel}
onMouseDown={handleScrollContainerMouseDown}
onWheel={handleTranscriptWheel}
onMouseDown={handleTranscriptMouseDown}
onMouseMove={handleScrollContainerMouseMove}
onMouseUp={handleScrollContainerMouseUp}
onTouchMove={markUserScrollIntent}
onKeyDown={handleScrollContainerKeyDown}
onTouchMove={handleTranscriptTouchMove}
onKeyDown={handleTranscriptKeyDown}
onScroll={handleScroll}
onContextMenu={transcriptContextMenu.onContextMenu}
role="log"
Expand Down Expand Up @@ -1012,7 +1157,7 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
{transcriptContextMenu.menu}
{!autoScroll && (
<button
onClick={jumpToBottom}
onClick={handleJumpToBottom}
type="button"
className="assistant-chip font-primary text-foreground hover:assistant-chip-hover absolute bottom-2 left-1/2 z-20 -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-all duration-200 hover:scale-105 active:scale-95"
>
Expand Down
Loading
Loading