From b4770a98bb37e47e4c741899321dacea250f46e4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 26 May 2026 17:20:34 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20make=20/btw=20side=20?= =?UTF-8?q?question=20stickiness=20dismissable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an explicit Dismiss control (PinOff icon) in the side-question header that permanently exempts that side question from the transcript scroll-hold ('stickiness') for the rest of the workspace session. This gives users a deterministic escape hatch when the existing heuristic release paths (wheel / mousedown / key / touch / jump-to-bottom) misbehave. --- src/browser/components/ChatPane/ChatPane.tsx | 72 ++++++++++++++++- .../Messages/MessageRenderer.test.tsx | 77 +++++++++++++++++++ .../features/Messages/MessageRenderer.tsx | 8 ++ src/browser/features/Messages/UserMessage.tsx | 32 +++++++- 4 files changed, 187 insertions(+), 2 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 64eced91dc..c18e688685 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -494,10 +494,25 @@ const ChatPaneContent: React.FC = (props) => { const activeSideQuestionScrollHoldTargetRef = useRef(null); + // User-dismissed side question stickiness: any historyId in this set is + // permanently exempt from the scroll-hold for the lifetime of the workspace + // session. This is the deterministic escape hatch from the various heuristic + // hold-release paths (wheel / mousedown / key / touch / jump-to-bottom), which + // have been the source of recurring bugs. The set is keyed by the side + // question user-message historyId, but is also consulted when the associated + // side-answer is the active target (the hold logic aligns to the question + // row in both cases). + const dismissedSideQuestionStickyIdsRef = useRef>(new Set()); + const clearActiveSideQuestionScrollHold = useCallback(() => { activeSideQuestionScrollHoldTargetRef.current = null; }, []); + const handleDismissSideQuestionSticky = useCallback((historyId: string) => { + dismissedSideQuestionStickyIdsRef.current.add(historyId); + activeSideQuestionScrollHoldTargetRef.current = null; + }, []); + useLayoutEffect(() => { sideQuestionScrollHoldRef.current = { initialized: false, @@ -506,6 +521,11 @@ const ChatPaneContent: React.FC = (props) => { heldSideAnswerIds: new Set(), }; activeSideQuestionScrollHoldTargetRef.current = null; + // A workspace switch unloads the side-question rows that were dismissed, + // so the dismissal set is no longer meaningful. Reset to avoid leaking + // historyIds across workspaces (and to allow a fresh first-time hold if + // the same workspace is later reopened with new branches). + dismissedSideQuestionStickyIdsRef.current = new Set(); }, [workspaceId]); useLayoutEffect(() => { @@ -517,6 +537,48 @@ const ChatPaneContent: React.FC = (props) => { findSideQuestionScrollHoldTarget(deferredMessages, sideQuestionScrollHoldRef.current); sideQuestionScrollHoldRef.current = nextState; + const dismissedIds = dismissedSideQuestionStickyIdsRef.current; + // The dismiss set tracks the side-question row's historyId. Some active + // holds use the side-answer's historyId as the target when the question + // row is not yet present in the displayed slice; resolve both directions + // by checking if the active target's neighbour pair has been dismissed. + const isTargetDismissed = (historyId: string | null | undefined): boolean => { + if (!historyId) return false; + if (dismissedIds.has(historyId)) return true; + const index = deferredMessages.findIndex( + (message) => + (message.type === "user" || message.type === "assistant") && + message.historyId === historyId + ); + if (index === -1) return false; + const message = deferredMessages[index]; + if (message.type === "assistant" && message.isSideAnswer === true) { + const previous = deferredMessages[index - 1]; + if ( + previous?.type === "user" && + previous.isSideQuestion === true && + dismissedIds.has(previous.historyId) + ) { + return true; + } + } + if (message.type === "user" && message.isSideQuestion === true) { + const next = deferredMessages[index + 1]; + if ( + next?.type === "assistant" && + next.isSideAnswer === true && + dismissedIds.has(next.historyId) + ) { + return true; + } + } + return false; + }; + + if (isTargetDismissed(activeSideQuestionScrollHoldTargetRef.current)) { + activeSideQuestionScrollHoldTargetRef.current = null; + } + const activeTargetHistoryId = activeSideQuestionScrollHoldTargetRef.current; const activeHold = findActiveSideQuestionScrollHoldTarget( deferredMessages, @@ -524,7 +586,10 @@ const ChatPaneContent: React.FC = (props) => { ); const continuingTargetHistoryId = activeHold.targetHistoryId === activeTargetHistoryId ? activeHold.targetHistoryId : undefined; - const shouldStartHold = detectedTargetHistoryId !== undefined && autoScroll; + const shouldStartHold = + detectedTargetHistoryId !== undefined && + autoScroll && + !isTargetDismissed(detectedTargetHistoryId); const targetHistoryId = shouldStartHold ? detectedTargetHistoryId : continuingTargetHistoryId; if (!targetHistoryId) { @@ -1237,6 +1302,11 @@ const ChatPaneContent: React.FC = (props) => { ? userMessageNavigationByHistoryId?.get(msg.historyId) : undefined } + onDismissSideQuestionSticky={ + msg.type === "user" && msg.isSideQuestion === true + ? handleDismissSideQuestionSticky + : undefined + } /> )} {/* Show collapsed indicator after the first item in a bash_output group */} diff --git a/src/browser/features/Messages/MessageRenderer.test.tsx b/src/browser/features/Messages/MessageRenderer.test.tsx index eee5d7f240..980e49d8ee 100644 --- a/src/browser/features/Messages/MessageRenderer.test.tsx +++ b/src/browser/features/Messages/MessageRenderer.test.tsx @@ -222,6 +222,83 @@ Live goal accounting at limit: }); }); +describe("MessageRenderer /btw side-question rows", () => { + beforeEach(() => { + globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; + globalThis.document = globalThis.window.document; + globalThis.localStorage = globalThis.window.localStorage; + }); + + afterEach(() => { + cleanup(); + + globalThis.window = undefined as unknown as Window & typeof globalThis; + globalThis.document = undefined as unknown as Document; + globalThis.localStorage = undefined as unknown as Storage; + }); + + function makeSideQuestionMessage(historyId: string): DisplayedMessage { + return { + type: "user", + id: historyId, + historyId, + content: "Why is the sky blue?", + historySequence: 1, + isSideQuestion: true, + }; + } + + test("does not render a dismiss-sticky control when no handler is supplied", () => { + const message = makeSideQuestionMessage("btw-no-handler"); + + const { queryByLabelText } = render( + + + + ); + + expect(queryByLabelText("Dismiss side question sticky position")).toBeNull(); + }); + + test("renders a dismiss-sticky control on side-question rows and forwards the historyId", () => { + const message = makeSideQuestionMessage("btw-with-handler"); + const dismissed: string[] = []; + + const { getByLabelText } = render( + + dismissed.push(historyId)} + /> + + ); + + const button = getByLabelText("Dismiss side question sticky position"); + expect(button).toBeDefined(); + (button as HTMLButtonElement).click(); + expect(dismissed).toEqual(["btw-with-handler"]); + }); + + test("does not render the dismiss control on non-side-question user rows", () => { + const message: DisplayedMessage = { + type: "user", + id: "plain-user", + historyId: "plain-user", + content: "Hello", + historySequence: 1, + }; + + const { queryByLabelText } = render( + + undefined} /> + + ); + + // The dismiss button only renders inside the side-question header chrome. + expect(queryByLabelText("Dismiss side question sticky position")).toBeNull(); + }); +}); + describe("MessageRenderer generated image rows", () => { beforeEach(() => { globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; diff --git a/src/browser/features/Messages/MessageRenderer.tsx b/src/browser/features/Messages/MessageRenderer.tsx index 1e720f6042..6db58d3590 100644 --- a/src/browser/features/Messages/MessageRenderer.tsx +++ b/src/browser/features/Messages/MessageRenderer.tsx @@ -34,6 +34,12 @@ interface MessageRendererProps { taskReportLinking?: TaskReportLinking; /** Navigation info for user messages (backward/forward between user messages) */ userMessageNavigation?: UserMessageNavigation; + /** + * For /btw side-question rows: opt-in dismissal of the transcript scroll-hold + * ("stickiness") for this side question. When provided, UserMessage renders a + * small dismiss control in the side-question header. + */ + onDismissSideQuestionSticky?: (historyId: string) => void; } function getMessageHistoryId(message: DisplayedMessage): string | undefined { @@ -82,6 +88,7 @@ export const MessageRenderer = React.memo( bashOutputGroup, taskReportLinking, userMessageNavigation, + onDismissSideQuestionSticky, }) => { let renderedMessage: React.ReactNode; @@ -95,6 +102,7 @@ export const MessageRenderer = React.memo( onEdit={onEditUserMessage} isCompacting={isCompacting} navigation={userMessageNavigation} + onDismissSticky={onDismissSideQuestionSticky} /> ); break; diff --git a/src/browser/features/Messages/UserMessage.tsx b/src/browser/features/Messages/UserMessage.tsx index 143ab6ef11..1583eb836c 100644 --- a/src/browser/features/Messages/UserMessage.tsx +++ b/src/browser/features/Messages/UserMessage.tsx @@ -7,6 +7,7 @@ import { UserMessageContent } from "./UserMessageContent"; import { GoalSyntheticMessageContent } from "./GoalSyntheticMessageContent"; import { TerminalOutput } from "./TerminalOutput"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; +import { TooltipIfPresent } from "@/browser/components/Tooltip/Tooltip"; import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; import { copyToClipboard } from "@/browser/utils/clipboard"; import { @@ -23,6 +24,7 @@ import { ClipboardCheck, MessageCircleQuestion, Pencil, + PinOff, Target, } from "lucide-react"; import { @@ -49,6 +51,14 @@ interface UserMessageProps { clipboardWriteText?: (data: string) => Promise; /** Navigation info for backward/forward between user messages */ navigation?: UserMessageNavigation; + /** + * For /btw side-question rows only: callback invoked when the user clicks the + * dismiss-stickiness control in the side-question header. The ChatPane uses + * this to permanently exempt this side question from the transcript + * scroll-hold for the rest of the workspace session. When omitted, no + * dismiss control is rendered. + */ + onDismissSticky?: (historyId: string) => void; } export const UserMessage: React.FC = ({ @@ -58,6 +68,7 @@ export const UserMessage: React.FC = ({ isCompacting, clipboardWriteText = copyToClipboard, navigation, + onDismissSticky, }) => { const isSynthetic = message.isSynthetic === true; const isGoalContinuation = message.isGoalContinuation === true; @@ -236,7 +247,26 @@ export const UserMessage: React.FC = ({ >
{messageWindow} From 3b2b5cf89aa731d67c109da93993314e9bccd6b0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 26 May 2026 17:34:14 -0500 Subject: [PATCH 2/2] tests: add story for /btw side-question dismiss control Adds an App/Chat/Messages > SideQuestionWithDismiss story that renders a /btw Q/A pair with surrounding main-agent turns and a play test that locates the dismiss-sticky control by aria-label and clicks it. Also extends the createAssistantMessage story mock to accept muxMetadata so the assistant row can be tagged as a side-question-answer. --- .../Messages/MessageRenderer.stories.tsx | 76 +++++++++++++++++++ src/browser/stories/mocks/messages.ts | 6 ++ 2 files changed, 82 insertions(+) diff --git a/src/browser/features/Messages/MessageRenderer.stories.tsx b/src/browser/features/Messages/MessageRenderer.stories.tsx index 7667e80ec1..e5fa26478b 100644 --- a/src/browser/features/Messages/MessageRenderer.stories.tsx +++ b/src/browser/features/Messages/MessageRenderer.stories.tsx @@ -218,6 +218,82 @@ export const SyntheticAutoResumeMessages: AppStory = { ), }; +/** + * /btw side-question Q/A pair with the dismiss-stickiness control visible. + * + * The dismiss control is rendered inline in the "Side question" header (top + * right of the user row's stripe) and is plumbed from `ChatPane` to clear the + * transcript scroll-hold for that side question for the rest of the session. + * The `play` test asserts the control is rendered and clicks it to exercise + * the wired-up dismissal callback. + */ +export const SideQuestionWithDismiss: AppStory = { + parameters: { chromatic: { modes: CHROMATIC_SMOKE_MODES } }, + render: () => ( + { + collapseLeftSidebar(); + return setupSimpleChatStory({ + workspaceId: "ws-side-question", + messages: [ + createUserMessage("msg-1", "Refactor the request retry logic", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 240000, + }), + createAssistantMessage( + "msg-2", + "I'll start by reading the current retry implementation and outlining the changes.", + { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 230000, + } + ), + createUserMessage("msg-3-btw", "/btw what does exponential backoff mean?", { + historySequence: 3, + timestamp: STABLE_TIMESTAMP - 200000, + muxMetadata: { + type: "side-question", + rawCommand: "/btw what does exponential backoff mean?", + }, + }), + createAssistantMessage( + "msg-4-btw-answer", + "Exponential backoff doubles the wait between retries so a struggling service has time to recover instead of being hammered by a tight retry loop.", + { + historySequence: 4, + timestamp: STABLE_TIMESTAMP - 195000, + muxMetadata: { + type: "side-question-answer", + questionMessageId: "msg-3-btw", + }, + } + ), + createAssistantMessage( + "msg-5", + "Back to the main task — I'll switch the retry helper to use exponential backoff with jitter.", + { + historySequence: 5, + timestamp: STABLE_TIMESTAMP - 180000, + } + ), + ], + }); + }} + /> + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const dismissButton = await canvas.findByLabelText("Dismiss side question sticky position"); + await expect(dismissButton).toBeInTheDocument(); + // Click to exercise the dismissal pathway end-to-end (ChatPane handler + // clears the active scroll-hold ref and adds the row's historyId to the + // dismissed set). The button stays in the DOM after click — the visual + // change is the scroll behavior, which Chromatic captures via the static + // layout snapshot. + await userEvent.click(dismissButton); + }, +}; + export const GoalContinuationMessages: AppStory = { render: () => (