diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index 5c62e99faf8860..d5170b83e6bb35 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -8,7 +8,7 @@ import { addDisposableListener, animate, Dimension, getActiveElement, getContent import { DomEmitter } from '../../event.js'; import { IMouseWheelEvent } from '../../mouseEvent.js'; import { EventType as TouchEventType, Gesture, GestureEvent } from '../../touch.js'; -import { SmoothScrollableElement } from '../scrollbar/scrollableElement.js'; +import { IOverviewRulerLayoutInfo, SmoothScrollableElement } from '../scrollbar/scrollableElement.js'; import { distinct, equals, splice } from '../../../common/arrays.js'; import { Delayer, disposableTimeout } from '../../../common/async.js'; import { memoize } from '../../../common/decorators.js'; @@ -233,6 +233,7 @@ export interface IListView extends ISpliceable, IDisposable { readonly domNode: HTMLElement; readonly containerDomNode: HTMLElement; readonly scrollableElementDomNode: HTMLElement; + getOverviewRulerLayoutInfo(): IOverviewRulerLayoutInfo; readonly length: number; readonly contentHeight: number; readonly contentWidth: number; @@ -343,6 +344,7 @@ export class ListView implements IListView { get onWillScroll(): Event { return this.scrollableElement.onWillScroll; } get containerDomNode(): HTMLElement { return this.rowsContainer; } get scrollableElementDomNode(): HTMLElement { return this.scrollableElement.getDomNode(); } + getOverviewRulerLayoutInfo(): IOverviewRulerLayoutInfo { return this.scrollableElement.getOverviewRulerLayoutInfo(); } private _horizontalScrolling: boolean = false; private get horizontalScrolling(): boolean { return this._horizontalScrolling; } diff --git a/src/vs/base/browser/ui/list/listWidget.ts b/src/vs/base/browser/ui/list/listWidget.ts index 4b191b9dd833e0..3fe01ef4dc344f 100644 --- a/src/vs/base/browser/ui/list/listWidget.ts +++ b/src/vs/base/browser/ui/list/listWidget.ts @@ -12,6 +12,7 @@ import { IKeyboardEvent, StandardKeyboardEvent } from '../../keyboardEvent.js'; import { Gesture } from '../../touch.js'; import { alert, AriaRole } from '../aria/aria.js'; import { CombinedSpliceable } from './splice.js'; +import { IOverviewRulerLayoutInfo } from '../scrollbar/scrollableElement.js'; import { ScrollableElementChangeOptions } from '../scrollbar/scrollableElementOptions.js'; import { binarySearch, range } from '../../../common/arrays.js'; import { timeout } from '../../../common/async.js'; @@ -2029,6 +2030,14 @@ export class List implements ISpliceable, IDisposable { return this.view.scrollableElementDomNode; } + getOverviewRulerLayoutInfo(): IOverviewRulerLayoutInfo { + return this.view.getOverviewRulerLayoutInfo(); + } + + getElementHeight(index: number): number { + return this.view.elementHeight(index); + } + getElementID(index: number): string { return this.view.getElementDomId(index); } diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 0baaf62849795f..1ee4eafd084e79 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -38,6 +38,7 @@ import { autorun, constObservable } from '../../../common/observable.js'; import { alert } from '../aria/aria.js'; import { IMouseWheelEvent } from '../../mouseEvent.js'; import { type IHoverLifecycleOptions } from '../hover/hover.js'; +import { IOverviewRulerLayoutInfo } from '../scrollbar/scrollableElement.js'; class TreeElementsDragAndDropData extends ElementsDragAndDropData { @@ -2778,6 +2779,10 @@ export abstract class AbstractTree implements IDisposable return this.view.getHTMLElement(); } + getOverviewRulerLayoutInfo(): IOverviewRulerLayoutInfo { + return this.view.getOverviewRulerLayoutInfo(); + } + get contentHeight(): number { return this.view.contentHeight; } @@ -2814,6 +2819,22 @@ export abstract class AbstractTree implements IDisposable return this.view.scrollHeight; } + getElementTop(element: TRef): number { + const index = this.model.getListIndex(element); + if (index === -1) { + return 0; + } + return this.view.getElementTop(index); + } + + getElementHeight(element: TRef): number { + const index = this.model.getListIndex(element); + if (index === -1) { + return 0; + } + return this.view.getElementHeight(index); + } + get renderHeight(): number { return this.view.renderHeight; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index d875fe8cfec5ed..3d717832c5ea4c 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -86,6 +86,9 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'age content.push(localize('workbench.action.chat.nextCodeBlock', 'To focus the next code block within a response, invoke the Chat: Next Code Block command{0}.', '')); content.push(localize('workbench.action.chat.nextUserPrompt', 'To navigate to the next user prompt in the conversation, invoke the Next User Prompt command{0}.', '')); content.push(localize('workbench.action.chat.previousUserPrompt', 'To navigate to the previous user prompt in the conversation, invoke the Previous User Prompt command{0}.', '')); + if (type === 'panelChat' || type === 'agentView') { + content.push(localize('chat.scrollbarMarkers', 'When enabled, the chat scrollbar displays visual markers for prompts, questions, file changes, and errors. These markers are mouse-only; use the Next and Previous User Prompt commands above to navigate by keyboard.')); + } content.push(localize('workbench.action.chat.announceConfirmation', 'To focus pending chat confirmation dialogs, invoke the Focus Chat Confirmation Status command{0}.', '')); content.push(localize('chat.showHiddenTerminals', 'If there are any hidden chat terminals, you can view them by invoking the View Hidden Chat Terminals command{0}.', '')); content.push(localize('chat.focusMostRecentTerminal', 'To focus the last chat terminal that ran a tool, invoke the Focus Most Recent Chat Terminal command{0}.', ``)); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatPromptNavigationActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatPromptNavigationActions.ts index 93c6417d90b185..7546ab4efd7cc2 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatPromptNavigationActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatPromptNavigationActions.ts @@ -6,55 +6,585 @@ import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize2 } from '../../../../../nls.js'; -import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { + Action2, + registerAction2, +} from '../../../../../platform/actions/common/actions.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { CHAT_CATEGORY } from './chatActions.js'; import { IChatWidgetService } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatRequestViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; +import { ChatScrollbarPromptMarkerClickBehavior } from '../../common/constants.js'; +import { + IChatPendingDividerViewModel, + IChatRequestViewModel, + IChatResponseViewModel, + isRequestVM, + isResponseVM, +} from '../../common/model/chatViewModel.js'; +import { isAskQuestionsToolInvocation } from '../widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.js'; -export function registerChatPromptNavigationActions() { - registerAction2(class NextUserPromptAction extends Action2 { - constructor() { - super({ - id: 'workbench.action.chat.nextUserPrompt', - title: localize2('interactive.nextUserPrompt.label', "Next User Prompt"), - keybinding: { - primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.DownArrow, - weight: KeybindingWeight.WorkbenchContrib, - when: ChatContextKeys.inChatSession, - }, - precondition: ChatContextKeys.enabled, - f1: true, - category: CHAT_CATEGORY, - }); +type ChatPromptNavigationItem = + | IChatRequestViewModel + | IChatResponseViewModel + | IChatPendingDividerViewModel; + +/** + * The semantic category of a scrollbar marker. Each type maps to a distinct + * color and lane assignment, allowing users to visually distinguish different + * kinds of chat turns at a glance in the overview ruler. + */ +export const enum ChatScrollbarPromptMarkerType { + /** A user-authored prompt (request). Rendered in the right lane. */ + Prompt = 'prompt', + /** A response containing an ask-questions tool invocation or question carousel. Rendered in the left lane. */ + AskQuestion = 'askQuestion', + /** A response containing one or more file edits. Rendered full-width. */ + FileChange = 'fileChange', + /** A system-initiated compaction request (e.g. `/compact`). Rendered full-width. */ + Compaction = 'compaction', + /** A response that ended in an error. Rendered full-width. */ + Error = 'error', +} + +/** + * Which horizontal portion of the scrollbar the marker occupies. + * Left and right lanes each take 50% of the scrollbar width; full spans the entire width. + * This mirrors Monaco's overview ruler lane layout. + */ +export const enum ChatScrollbarPromptMarkerLane { + Left = 'left', + Right = 'right', + Full = 'full', +} + +/** + * The host widget that marker clicks are dispatched to. + */ +export interface IChatScrollbarPromptMarkerTarget { + reveal(item: IChatRequestViewModel | IChatResponseViewModel): void; + focusItem(item: IChatRequestViewModel | IChatResponseViewModel): void; +} + +/** + * Describes a single marker to be rendered on the chat scrollbar overview ruler. + * + * A descriptor is produced for each user prompt (request row) and, conditionally, + * for its paired response row. File-change responses may produce multiple + * descriptors — one per logical edit cluster within the response — so that + * individual file operations are individually navigable. + */ +export interface IChatScrollbarPromptMarkerDescriptor { + /** Unique identifier for this marker. May include a suffix for multi-marker responses (e.g. `responseId#fileChange0`). */ + readonly id: string; + /** The ID of the request that this marker belongs to. */ + readonly requestId: string; + /** The request view model that originated this marker's turn. */ + readonly request: IChatRequestViewModel; + /** The chat row (request or response) that this marker positions itself against and navigates to when clicked. */ + readonly target: IChatRequestViewModel | IChatResponseViewModel; + /** The semantic type, determining color and lane. */ + readonly markerType: ChatScrollbarPromptMarkerType; + /** Which horizontal lane the marker occupies. */ + readonly lane: ChatScrollbarPromptMarkerLane; + /** Z-index ordering value; higher-priority markers render above lower ones when overlapping. */ + readonly priority: number; + /** Minimum pixel height to keep the marker visible even for very short chat rows. */ + readonly minHeight: number; + /** + * When set, positions the marker at a fractional offset within the target row's + * rendered height (0 = top, 1 = bottom). Used by file-change markers that represent + * a sub-region of a large response. When undefined, the marker spans the full row. + */ + readonly topRatio?: number; + /** + * When set, controls the fractional height of the marker relative to the target + * row's rendered height. Used together with {@link topRatio} for sub-row markers. + * When undefined, the marker uses the full row height. + */ + readonly heightRatio?: number; +} + +/** + * Returns all request view models from the given chat items. + * No filtering or deduplication is applied here — system-initiated filtering + * and deduplication by message text happen in {@link getScrollbarPromptMarkerDescriptors}. + */ +export function getRequestViewModels( + items: readonly ChatPromptNavigationItem[], +): IChatRequestViewModel[] { + return items.filter((item): item is IChatRequestViewModel => + isRequestVM(item), + ); +} + +/** + * Computes all scrollbar marker descriptors for a given set of chat items. + * + * The algorithm works in three phases: + * 1. **Index responses** by their owning request ID for O(1) lookup. + * 2. **Deduplicate requests** by message text (or by ID for compaction), keeping + * only the latest attempt. System-initiated requests are excluded unless they + * are compaction requests. + * 3. **Emit descriptors** — one prompt marker per surviving request, plus zero or + * more response markers (error, ask-question, or file-change) for the paired + * response. File-change responses may emit multiple markers, one per logical + * edit cluster. + */ +export function getScrollbarPromptMarkerDescriptors( + items: readonly ChatPromptNavigationItem[], +): IChatScrollbarPromptMarkerDescriptor[] { + const latestByDedupKey = new Map(); + const responseByRequestId = new Map(); + + // Phase 1: Index responses by request ID + for (const item of items) { + if (isResponseVM(item)) { + responseByRequestId.set(item.requestId, item); } + } + + // Phase 2: Deduplicate requests, keeping the latest attempt per message text + for (const item of items) { + if (!isRequestVM(item)) { + continue; + } + + // Skip system-initiated requests unless they are compaction + if (item.isSystemInitiated && !isCompactionRequest(item)) { + continue; + } + + // Compaction requests are deduplicated by ID (each is unique); + // all other requests are deduplicated by message text + const dedupKey = + isCompactionRequest(item) + ? item.id + : item.messageText; + const previous = latestByDedupKey.get(dedupKey); + if ( + !previous || + item.attempt > previous.attempt || + (item.attempt === previous.attempt && + item.timestamp >= previous.timestamp) + ) { + latestByDedupKey.set(dedupKey, item); + } + } + + // Build the set of request IDs that survived deduplication + const selectedRequestIds = new Set(); + for (const item of items) { + if (!isRequestVM(item)) { + continue; + } + + if (item.isSystemInitiated && !isCompactionRequest(item)) { + continue; + } + + const dedupKey = + isCompactionRequest(item) + ? item.id + : item.messageText; + if (latestByDedupKey.get(dedupKey) === item) { + selectedRequestIds.add(item.id); + } + } + + // Phase 3: Emit descriptors for each surviving request and its paired response + const descriptors: IChatScrollbarPromptMarkerDescriptor[] = []; + for (const item of items) { + if (!isRequestVM(item) || !selectedRequestIds.has(item.id)) { + continue; + } + + // Emit a prompt or compaction marker for the request row itself + const requestMarkerType = isCompactionRequest(item) + ? ChatScrollbarPromptMarkerType.Compaction + : ChatScrollbarPromptMarkerType.Prompt; + descriptors.push({ + id: item.id, + requestId: item.id, + request: item, + target: item, + markerType: requestMarkerType, + lane: getMarkerLane(requestMarkerType), + priority: getMarkerPriority(requestMarkerType), + minHeight: 4, + }); + + // Emit zero or more markers for the paired response row + descriptors.push(...getResponseMarkerDescriptors(item, responseByRequestId.get(item.id))); + } + + return descriptors; +} + +/** + * Computes marker descriptors for a response, classifying it by its most + * significant semantic property. The classification priority is: + * 1. Error — a failed response always wins + * 2. Ask-question — a response containing an ask-questions tool or carousel + * 3. File-change — a response containing file edits (may produce multiple markers) + * 4. File-change fallback — when the request has editedFileEvents but the response + * has no structured edit parts (e.g. the response is missing or incomplete) + * + * If none of these apply, no response marker is emitted. + */ +function getResponseMarkerDescriptors( + request: IChatRequestViewModel, + response: IChatResponseViewModel | undefined, +): IChatScrollbarPromptMarkerDescriptor[] { + if (!response) { + return hasFileChangeRequest(request) + ? [{ + id: `${request.id}-fileChange`, + requestId: request.id, + request, + target: request, + markerType: ChatScrollbarPromptMarkerType.FileChange, + lane: getMarkerLane(ChatScrollbarPromptMarkerType.FileChange), + priority: getMarkerPriority(ChatScrollbarPromptMarkerType.FileChange), + minHeight: 4, + }] + : []; + } + + if (response.errorDetails) { + return [{ + id: response.id, + requestId: request.id, + request, + target: response, + markerType: ChatScrollbarPromptMarkerType.Error, + lane: getMarkerLane(ChatScrollbarPromptMarkerType.Error), + priority: getMarkerPriority(ChatScrollbarPromptMarkerType.Error), + minHeight: 4, + }]; + } + + if (hasAskQuestionsResponse(response)) { + return [{ + id: response.id, + requestId: request.id, + request, + target: response, + markerType: ChatScrollbarPromptMarkerType.AskQuestion, + lane: getMarkerLane(ChatScrollbarPromptMarkerType.AskQuestion), + priority: getMarkerPriority(ChatScrollbarPromptMarkerType.AskQuestion), + minHeight: 4, + }]; + } + + const fileChangeDescriptors = getFileChangeResponseDescriptors(request, response); + if (fileChangeDescriptors.length > 0) { + return fileChangeDescriptors; + } + + if (hasFileChangeRequest(request)) { + return [{ + id: response.id, + requestId: request.id, + request, + target: response, + markerType: ChatScrollbarPromptMarkerType.FileChange, + lane: getMarkerLane(ChatScrollbarPromptMarkerType.FileChange), + priority: getMarkerPriority(ChatScrollbarPromptMarkerType.FileChange), + minHeight: 4, + }]; + } + + return []; +} + +/** + * Maps a marker type to its horizontal lane assignment. + * - Prompt → right lane (user prompts on the right) + * - AskQuestion → left lane (questions on the left) + * - All others → full width + */ +function getMarkerLane( + markerType: ChatScrollbarPromptMarkerType, +): ChatScrollbarPromptMarkerLane { + switch (markerType) { + case ChatScrollbarPromptMarkerType.AskQuestion: + return ChatScrollbarPromptMarkerLane.Left; + case ChatScrollbarPromptMarkerType.Prompt: + return ChatScrollbarPromptMarkerLane.Right; + default: + return ChatScrollbarPromptMarkerLane.Full; + } +} + +/** + * Maps a marker type to its z-index priority for overlap resolution. + * Higher values render above lower ones when markers collide vertically. + */ +function getMarkerPriority( + markerType: ChatScrollbarPromptMarkerType, +): number { + switch (markerType) { + case ChatScrollbarPromptMarkerType.Error: + return 100; + case ChatScrollbarPromptMarkerType.Compaction: + return 90; + case ChatScrollbarPromptMarkerType.FileChange: + return 80; + case ChatScrollbarPromptMarkerType.AskQuestion: + return 70; + default: + return 60; + } +} + +/** + * Returns true if the request was initiated by the `/compact` slash command. + */ +function isCompactionRequest(request: IChatRequestViewModel): boolean { + return request.slashCommand?.name === 'compact'; +} + +/** + * Returns true if the response contains an ask-questions tool invocation + * (identified by tool ID) or a question carousel part. + */ +function hasAskQuestionsResponse(response: IChatResponseViewModel | undefined): boolean { + if (!response) { + return false; + } + + return response.model.entireResponse.value.some(part => + (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') + ? isAskQuestionsToolInvocation(part) + : part.kind === 'questionCarousel' + ); +} + +/** + * Computes one or more file-change marker descriptors for a response by + * grouping its edit parts into logical clusters. Each cluster typically + * corresponds to a single file write operation (e.g. `copilot_createFile` + * followed by its `textEditGroup`), allowing users to navigate to + * individual file operations within a large response. + * + * Each descriptor carries {@link topRatio} and {@link heightRatio} so the + * controller can position the marker at the correct sub-region of the + * response row rather than spanning the entire row. + */ +function getFileChangeResponseDescriptors( + request: IChatRequestViewModel, + response: IChatResponseViewModel, +): IChatScrollbarPromptMarkerDescriptor[] { + const parts = response.model.entireResponse.value; + const groups = groupFileEditParts(parts); + if (groups.length === 0) { + return []; + } + + return groups.map((group, index) => ({ + id: groups.length === 1 ? response.id : `${response.id}#fileChange${index}`, + requestId: request.id, + request, + target: response, + markerType: ChatScrollbarPromptMarkerType.FileChange, + lane: getMarkerLane(ChatScrollbarPromptMarkerType.FileChange), + priority: getMarkerPriority(ChatScrollbarPromptMarkerType.FileChange), + minHeight: 4, + topRatio: group.startIndex / parts.length, + heightRatio: Math.max((group.endExclusive - group.startIndex) / parts.length, 1 / parts.length), + })); +} + +/** + * Groups response parts into logical file-edit clusters. A cluster starts at + * a file-write tool invocation (e.g. `copilot_createFile`, `copilot_replaceString`) + * and extends through all consecutive edit parts (`textEditGroup`, `notebookEditGroup`, + * `externalEdit`) that follow it. Non-edit parts between edit parts do not break + * the cluster, but a new write tool invocation starts a new cluster. + * + * If no write tool invocations are found, each edit part becomes its own cluster. + */ +function groupFileEditParts( + parts: readonly IChatResponseViewModel['model']['entireResponse']['value'][number][], +): Array<{ startIndex: number; endExclusive: number }> { + const groups: Array<{ startIndex: number; endExclusive: number }> = []; + let pendingWriteToolIndex: number | undefined; + let currentGroup: { startIndex: number; endExclusive: number } | undefined; - run(accessor: ServicesAccessor, ...args: unknown[]) { - navigateUserPrompts(accessor, false); + for (let index = 0; index < parts.length; index++) { + const part = parts[index]; + + if (isFileWriteToolInvocation(part)) { + // A new write tool starts a new cluster — flush the previous one + if (currentGroup) { + groups.push(currentGroup); + currentGroup = undefined; + } + pendingWriteToolIndex = index; + continue; } - }); - - registerAction2(class PreviousUserPromptAction extends Action2 { - constructor() { - super({ - id: 'workbench.action.chat.previousUserPrompt', - title: localize2('interactive.previousUserPrompt.label', "Previous User Prompt"), - keybinding: { - primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.UpArrow, - weight: KeybindingWeight.WorkbenchContrib, - when: ChatContextKeys.inChatSession, - }, - precondition: ChatContextKeys.enabled, - f1: true, - category: CHAT_CATEGORY, - }); + + if (!isFileEditPart(part)) { + continue; } - run(accessor: ServicesAccessor, ...args: unknown[]) { - navigateUserPrompts(accessor, true); + if (!currentGroup) { + currentGroup = { + startIndex: pendingWriteToolIndex ?? index, + endExclusive: index + 1, + }; + } else { + currentGroup.endExclusive = index + 1; } - }); + } + + if (currentGroup) { + groups.push(currentGroup); + } + + // Fallback: if no write tool invocations were found, treat each edit part as its own cluster + if (groups.length > 0) { + return groups; + } + + return parts.flatMap((part, index) => isFileEditPart(part) + ? [{ startIndex: index, endExclusive: index + 1 }] + : []); +} + +/** + * Returns true if the response part represents a file edit + * (text edit group, notebook edit group, or external edit). + */ +function isFileEditPart( + part: IChatResponseViewModel['model']['entireResponse']['value'][number], +): boolean { + switch (part.kind) { + case 'textEditGroup': + case 'notebookEditGroup': + case 'externalEdit': + return true; + default: + return false; + } +} + +/** + * Returns true if the response part is a tool invocation that performs a + * file write operation (create, delete, replace, rename, etc.). + * These tool invocations mark the start of a new file-edit cluster. + */ +function isFileWriteToolInvocation( + part: IChatResponseViewModel['model']['entireResponse']['value'][number], +): boolean { + if (part.kind !== 'toolInvocation' && part.kind !== 'toolInvocationSerialized') { + return false; + } + + return /^copilot_(createFile|createDirectory|deleteFile|replaceString|multiReplaceString|insertEditIntoFile|applyPatch|renameFile|moveFile)$/i.test(part.toolId); +} + +/** + * Returns true if the request has recorded file-edit events + * (from `editedFileEvents` on the request model). This is used as a + * fallback signal for file-change markers when the response itself + * has no structured edit parts. + */ +function hasFileChangeRequest(request: IChatRequestViewModel): boolean { + return (request.editedFileEvents?.length ?? 0) > 0; +} + +export function getFocusedScrollbarPromptMarkerRequestId( + item: IChatRequestViewModel | IChatResponseViewModel | undefined, +): string | undefined { + if (!item) { + return undefined; + } + + if (isRequestVM(item)) { + return item.id; + } + + if (isResponseVM(item)) { + return item.requestId; + } + + return undefined; +} + +export function getFocusedScrollbarPromptMarkerId( + item: IChatRequestViewModel | IChatResponseViewModel | undefined, +): string | undefined { + return item?.id; +} + +export function applyScrollbarPromptMarkerClickBehavior( + target: IChatScrollbarPromptMarkerTarget, + item: IChatRequestViewModel | IChatResponseViewModel, + behavior: ChatScrollbarPromptMarkerClickBehavior, +): void { + if (behavior === ChatScrollbarPromptMarkerClickBehavior.Reveal) { + target.reveal(item); + return; + } + + target.reveal(item); + target.focusItem(item); +} + +export function registerChatPromptNavigationActions() { + registerAction2( + class NextUserPromptAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.nextUserPrompt', + title: localize2( + "interactive.nextUserPrompt.label", + "Next User Prompt", + ), + keybinding: { + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.DownArrow, + weight: KeybindingWeight.WorkbenchContrib, + when: ChatContextKeys.inChatSession, + }, + precondition: ChatContextKeys.enabled, + f1: true, + category: CHAT_CATEGORY, + }); + } + + run(accessor: ServicesAccessor, ...args: unknown[]) { + navigateUserPrompts(accessor, false); + } + }, + ); + + registerAction2( + class PreviousUserPromptAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.previousUserPrompt', + title: localize2( + "interactive.previousUserPrompt.label", + "Previous User Prompt", + ), + keybinding: { + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.UpArrow, + weight: KeybindingWeight.WorkbenchContrib, + when: ChatContextKeys.inChatSession, + }, + precondition: ChatContextKeys.enabled, + f1: true, + category: CHAT_CATEGORY, + }); + } + + run(accessor: ServicesAccessor, ...args: unknown[]) { + navigateUserPrompts(accessor, true); + } + }, + ); } function navigateUserPrompts(accessor: ServicesAccessor, reverse: boolean) { @@ -70,7 +600,7 @@ function navigateUserPrompts(accessor: ServicesAccessor, reverse: boolean) { } // Get all user prompts (requests) in the conversation - const userPrompts = items.filter((item): item is IChatRequestViewModel => isRequestVM(item)); + const userPrompts = getRequestViewModels(items); if (userPrompts.length === 0) { return; } @@ -82,11 +612,15 @@ function navigateUserPrompts(accessor: ServicesAccessor, reverse: boolean) { if (focused) { if (isRequestVM(focused)) { // If a request is focused, find its index in the user prompts array - currentIndex = userPrompts.findIndex(prompt => prompt.id === focused.id); + currentIndex = userPrompts.findIndex( + (prompt) => prompt.id === focused.id, + ); } else if (isResponseVM(focused)) { // If a response is focused, find the associated request's index // Response view models have a requestId property - currentIndex = userPrompts.findIndex(prompt => prompt.id === focused.requestId); + currentIndex = userPrompts.findIndex( + (prompt) => prompt.id === focused.requestId, + ); } } diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 3be853bea3f2e1..b755350ae4f971 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -438,6 +438,23 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.inlineReferences.style', "Controls how file and symbol references are displayed in chat messages."), default: 'box' }, + [ChatConfiguration.ScrollbarPromptMarkersEnabled]: { + type: 'boolean', + description: nls.localize('chat.scrollbarPromptMarkers.enabled', "Controls whether typed scrollbar markers are shown in the Chat View transcript scrollbar."), + default: false, + tags: ['experimental'], + }, + [ChatConfiguration.ScrollbarPromptMarkerClickBehavior]: { + type: 'string', + enum: ['revealAndFocus', 'reveal'], + enumDescriptions: [ + nls.localize('chat.scrollbarPromptMarkers.clickBehavior.revealAndFocus', "Reveal the target prompt and move keyboard focus to it."), + nls.localize('chat.scrollbarPromptMarkers.clickBehavior.reveal', "Reveal the target prompt without changing keyboard focus."), + ], + description: nls.localize('chat.scrollbarPromptMarkers.clickBehavior', "Controls what happens when you click a chat scrollbar prompt marker in the transcript scrollbar."), + default: 'revealAndFocus', + tags: ['experimental'], + }, [ChatConfiguration.EditorAssociations]: { type: 'object', markdownDescription: nls.localize('chat.editorAssociations', "Configure [glob patterns](https://aka.ms/vscode-glob-patterns) to editors for opening files from chat (for example `\"*.md\": \"vscode.markdown.preview.editor\"`)."), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index ca65a77adc157b..369ebcfb31e499 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -6,34 +6,75 @@ import * as dom from '../../../../../base/browser/dom.js'; import { IMouseWheelEvent } from '../../../../../base/browser/mouseEvent.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; -import { ITreeContextMenuEvent, ITreeElement, ITreeFilter } from '../../../../../base/browser/ui/tree/tree.js'; +import { + ITreeContextMenuEvent, + ITreeElement, + ITreeFilter, +} from '../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; -import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { + Disposable, + toDisposable, +} from '../../../../../base/common/lifecycle.js'; import { ScrollEvent } from '../../../../../base/common/scrollable.js'; import { URI } from '../../../../../base/common/uri.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { + IContextKey, + IContextKeyService, +} from '../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { WorkbenchObjectTree } from '../../../../../platform/list/browser/listService.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { asCssVariable, buttonSecondaryBackground, buttonSecondaryForeground, buttonSecondaryHoverBackground } from '../../../../../platform/theme/common/colorRegistry.js'; +import { + asCssVariable, + buttonSecondaryBackground, + buttonSecondaryForeground, + buttonSecondaryHoverBackground, +} from '../../../../../platform/theme/common/colorRegistry.js'; import { katexContainerClassName } from '../../../markdown/common/markedKatexExtension.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatFollowup, IChatSendRequestOptions, IChatService } from '../../common/chatService/chatService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; +import { + IChatFollowup, + IChatSendRequestOptions, + IChatService, +} from '../../common/chatService/chatService.js'; +import { + ChatAgentLocation, + ChatConfiguration, + ChatModeKind, +} from '../../common/constants.js'; import { IChatRequestModeInfo } from '../../common/model/chatModel.js'; -import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; +import { + IChatRequestViewModel, + IChatResponseViewModel, + IChatViewModel, + isRequestVM, + isResponseVM, +} from '../../common/model/chatViewModel.js'; import { ChatAccessibilityProvider } from '../accessibility/chatAccessibilityProvider.js'; -import { ChatTreeItem, IChatAccessibilityService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions } from '../chat.js'; +import { + ChatTreeItem, + IChatAccessibilityService, + IChatCodeBlockInfo, + IChatFileTreeInfo, + IChatListItemRendererOptions, +} from '../chat.js'; import { CodeBlockPart } from './chatContentParts/codeBlockPart.js'; -import { ChatListDelegate, ChatListItemRenderer, IChatListItemTemplate, IChatRendererDelegate } from './chatListRenderer.js'; +import { + ChatListDelegate, + ChatListItemRenderer, + IChatListItemTemplate, + IChatRendererDelegate, +} from './chatListRenderer.js'; import { ChatEditorOptions } from './chatOptions.js'; import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; +import { ChatScrollbarPromptMarkerController } from './chatScrollbarPromptMarkerController.js'; export interface IChatListWidgetStyles { listForeground?: string; @@ -41,9 +82,6 @@ export interface IChatListWidgetStyles { } export interface IChatListWidgetOptions { - /** - * Options for the list item renderer. - */ readonly rendererOptions?: IChatListItemRendererOptions; /** @@ -112,6 +150,11 @@ export interface IChatListWidgetOptions { */ readonly getCurrentModeInfo?: () => IChatRequestModeInfo | undefined; + /** + * Whether scrollbar prompt markers should be shown for this list. + */ + readonly scrollbarPromptMarkersEnabled?: boolean; + /** * The render style for the chat widget. Affects minimum height behavior. */ @@ -124,24 +167,34 @@ export interface IChatListWidgetOptions { * hover previews, etc. */ export class ChatListWidget extends Disposable { - //#region Events private readonly _onDidScroll = this._register(new Emitter()); readonly onDidScroll: Event = this._onDidScroll.event; - private readonly _onDidChangeContentHeight = this._register(new Emitter()); - readonly onDidChangeContentHeight: Event = this._onDidChangeContentHeight.event; + private readonly _onDidChangeContentHeight = this._register( + new Emitter(), + ); + readonly onDidChangeContentHeight: Event = + this._onDidChangeContentHeight.event; - private readonly _onDidClickFollowup = this._register(new Emitter()); - readonly onDidClickFollowup: Event = this._onDidClickFollowup.event; + private readonly _onDidClickFollowup = this._register( + new Emitter(), + ); + readonly onDidClickFollowup: Event = + this._onDidClickFollowup.event; private readonly _onDidFocus = this._register(new Emitter()); readonly onDidFocus: Event = this._onDidFocus.event; - private readonly _onDidChangeItemHeight = this._register(new Emitter<{ element: ChatTreeItem; height: number }>()); + private readonly _onDidChangeItemHeight = this._register( + new Emitter<{ element: ChatTreeItem; height: number }>(), + ); /** Event fired when an item's height changes. Used for dynamic layout mode. */ - readonly onDidChangeItemHeight: Event<{ element: ChatTreeItem; height: number }> = this._onDidChangeItemHeight.event; + readonly onDidChangeItemHeight: Event<{ + element: ChatTreeItem; + height: number; + }> = this._onDidChangeItemHeight.event; /** * Event fired when a request item is clicked. @@ -192,9 +245,14 @@ export class ChatListWidget extends Disposable { private readonly _lastItemIdContextKey: IContextKey; private readonly _location: ChatAgentLocation | undefined; - private readonly _getCurrentLanguageModelId: (() => string | undefined) | undefined; - private readonly _getCurrentModeInfo: (() => IChatRequestModeInfo | undefined) | undefined; + private readonly _getCurrentLanguageModelId: + | (() => string | undefined) + | undefined; + private readonly _getCurrentModeInfo: + | (() => IChatRequestModeInfo | undefined) + | undefined; private readonly _renderStyle: 'compact' | 'minimal' | undefined; + private readonly _scrollbarPromptMarkerController: ChatScrollbarPromptMarkerController; //#endregion @@ -228,7 +286,10 @@ export class ChatListWidget extends Disposable { * Whether the list is scrolled to the bottom. */ get isScrolledToBottom(): boolean { - return this._tree.scrollTop + this._tree.renderHeight >= this._tree.scrollHeight - 2; + return ( + this._tree.scrollTop + this._tree.renderHeight >= + this._tree.scrollHeight - 2 + ); } /** @@ -238,20 +299,22 @@ export class ChatListWidget extends Disposable { return this._lastItem; } - - //#endregion constructor( container: HTMLElement, options: IChatListWidgetOptions, - @IInstantiationService private readonly instantiationService: IInstantiationService, + @IInstantiationService + private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IChatService private readonly chatService: IChatService, - @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IContextMenuService + private readonly contextMenuService: IContextMenuService, @ILogService private readonly logService: ILogService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IChatAccessibilityService private readonly chatAccessibilityService: IChatAccessibilityService, + @IConfigurationService + private readonly configurationService: IConfigurationService, + @IChatAccessibilityService + private readonly chatAccessibilityService: IChatAccessibilityService, ) { super(); @@ -259,47 +322,66 @@ export class ChatListWidget extends Disposable { this._location = options.location; this._getCurrentLanguageModelId = options.getCurrentLanguageModelId; this._getCurrentModeInfo = options.getCurrentModeInfo; - this._lastItemIdContextKey = ChatContextKeys.lastItemId.bindTo(this.contextKeyService); + this._lastItemIdContextKey = ChatContextKeys.lastItemId.bindTo( + this.contextKeyService, + ); this._container = container; // Toggle link-style for inline reference widgets based on configuration (single listener for all widgets) const updateInlineReferencesStyle = () => { - const style = this.configurationService.getValue(ChatConfiguration.InlineReferencesStyle); - this._container.classList.toggle('chat-inline-references-link-style', style === 'link'); + const style = this.configurationService.getValue( + ChatConfiguration.InlineReferencesStyle, + ); + this._container.classList.toggle( + 'chat-inline-references-link-style', + style === 'link', + ); }; updateInlineReferencesStyle(); - this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.InlineReferencesStyle)) { - updateInlineReferencesStyle(); - } - })); + this._register( + this.configurationService.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration(ChatConfiguration.InlineReferencesStyle)) { + updateInlineReferencesStyle(); + } + }), + ); - const scopedInstantiationService = this._register(this.instantiationService.createChild( - new ServiceCollection([IContextKeyService, this.contextKeyService]) - )); + const scopedInstantiationService = this._register( + this.instantiationService.createChild( + new ServiceCollection([IContextKeyService, this.contextKeyService]), + ), + ); this._renderStyle = options.renderStyle; // Create overflow widgets container - const overflowWidgetsContainer = options.overflowWidgetsDomNode ?? document.createElement('div'); + const overflowWidgetsContainer = + options.overflowWidgetsDomNode ?? document.createElement('div'); if (!options.overflowWidgetsDomNode) { - overflowWidgetsContainer.classList.add('chat-overflow-widget-container', 'monaco-editor'); + overflowWidgetsContainer.classList.add( + 'chat-overflow-widget-container', + 'monaco-editor', + ); this._container.append(overflowWidgetsContainer); this._register(toDisposable(() => overflowWidgetsContainer.remove())); } // Create editor options (use provided or create new) - const editorOptions = options.editorOptions ?? this._register(scopedInstantiationService.createInstance( - ChatEditorOptions, - options.viewId, - 'foreground', - options.inputEditorBackground ?? 'chat.requestEditor.background', - options.resultEditorBackground ?? 'chat.responseEditor.background' - )); + const editorOptions = + options.editorOptions ?? + this._register( + scopedInstantiationService.createInstance( + ChatEditorOptions, + options.viewId, + 'foreground', + options.inputEditorBackground ?? 'chat.requestEditor.background', + options.resultEditorBackground ?? 'chat.responseEditor.background', + ), + ); // Create delegate const delegate = scopedInstantiationService.createInstance( ChatListDelegate, - options.defaultElementHeight ?? 200 + options.defaultElementHeight ?? 200, ); // Create renderer delegate @@ -311,159 +393,216 @@ export class ChatListWidget extends Disposable { }; // Create renderer - this._renderer = this._register(scopedInstantiationService.createInstance( - ChatListItemRenderer, - editorOptions, - options.rendererOptions ?? {}, - rendererDelegate, - overflowWidgetsContainer, - this._viewModel, - )); + this._renderer = this._register( + scopedInstantiationService.createInstance( + ChatListItemRenderer, + editorOptions, + options.rendererOptions ?? {}, + rendererDelegate, + overflowWidgetsContainer, + this._viewModel, + ), + ); // Wire up renderer events - this._register(this._renderer.onDidClickFollowup(item => { - this._onDidClickFollowup.fire(item); - })); + this._register( + this._renderer.onDidClickFollowup((item) => { + this._onDidClickFollowup.fire(item); + }), + ); - this._register(this._renderer.onDidChangeItemHeight(e => { - this._updateElementHeight(e.element, e.height); + this._register( + this._renderer.onDidChangeItemHeight((e) => { + this._updateElementHeight(e.element, e.height); - // If the second-to-last item's height changed, update the last item's min height - const secondToLastItem = this._viewModel?.getItems().at(-2); - if (e.element.id === secondToLastItem?.id) { - this.updateLastItemMinHeight(); - } + // If the second-to-last item's height changed, update the last item's min height + const secondToLastItem = this._viewModel?.getItems().at(-2); + if (e.element.id === secondToLastItem?.id) { + this.updateLastItemMinHeight(); + } - this._onDidChangeItemHeight.fire(e); - })); + this._onDidChangeItemHeight.fire(e); + // A row's height changed, so marker geometry (derived from element heights) must be recomputed; refreshIfDimensionsChanged() is insufficient because scrollHeight may not have updated yet. + this._scrollbarPromptMarkerController.refresh(); + }), + ); // Handle rerun with agent or command detection internally - this._register(this._renderer.onDidClickRerunWithAgentOrCommandDetection(e => { - const request = this.chatService.getSession(e.sessionResource)?.getRequests().find(candidate => candidate.id === e.requestId); - if (request) { - const sendOptions: IChatSendRequestOptions = { - noCommandDetection: true, - attempt: request.attempt + 1, - location: this._location, - userSelectedModelId: this._getCurrentLanguageModelId?.(), - modeInfo: this._getCurrentModeInfo?.(), - }; - this.chatAccessibilityService.acceptRequest(e.sessionResource); - this.chatService.resendRequest(request, sendOptions).catch(e => this.logService.error('FAILED to rerun request', e)); - } - })); + this._register( + this._renderer.onDidClickRerunWithAgentOrCommandDetection((e) => { + const request = this.chatService + .getSession(e.sessionResource) + ?.getRequests() + .find((candidate) => candidate.id === e.requestId); + if (request) { + const sendOptions: IChatSendRequestOptions = { + noCommandDetection: true, + attempt: request.attempt + 1, + location: this._location, + userSelectedModelId: this._getCurrentLanguageModelId?.(), + modeInfo: this._getCurrentModeInfo?.(), + }; + this.chatAccessibilityService.acceptRequest(e.sessionResource); + this.chatService + .resendRequest(request, sendOptions) + .catch((e) => this.logService.error('FAILED to rerun request', e)); + } + }), + ); // Create drag-and-drop controller for reordering pending requests this._renderer.pendingDragController = this._register( - scopedInstantiationService.createInstance(ChatPendingDragController, this._container, () => this._viewModel) + scopedInstantiationService.createInstance( + ChatPendingDragController, + this._container, + () => this._viewModel, + ), ); // Create tree const styles = options.styles ?? {}; - this._tree = this._register(scopedInstantiationService.createInstance( - WorkbenchObjectTree, - 'ChatList', - this._container, - delegate, - [this._renderer], - { - identityProvider: { getId: (e: ChatTreeItem) => e.id }, - horizontalScrolling: false, - alwaysConsumeMouseWheel: false, - supportDynamicHeights: true, - hideTwistiesOfChildlessElements: true, - accessibilityProvider: this.instantiationService.createInstance(ChatAccessibilityProvider), - keyboardNavigationLabelProvider: { - getKeyboardNavigationLabel: (e: ChatTreeItem) => - isRequestVM(e) ? e.message : isResponseVM(e) ? e.response.value : '' + this._tree = this._register( + scopedInstantiationService.createInstance( + WorkbenchObjectTree, + 'ChatList', + this._container, + delegate, + [this._renderer], + { + identityProvider: { getId: (e: ChatTreeItem) => e.id }, + horizontalScrolling: false, + alwaysConsumeMouseWheel: false, + supportDynamicHeights: true, + hideTwistiesOfChildlessElements: true, + accessibilityProvider: this.instantiationService.createInstance( + ChatAccessibilityProvider, + ), + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: (e: ChatTreeItem) => + isRequestVM(e) + ? e.message + : isResponseVM(e) + ? e.response.value + : '', + }, + setRowLineHeight: false, + scrollToActiveElement: true, + filter: options.filter, + overrideStyles: { + listFocusBackground: styles.listBackground, + listInactiveFocusBackground: styles.listBackground, + listActiveSelectionBackground: styles.listBackground, + listFocusAndSelectionBackground: styles.listBackground, + listInactiveSelectionBackground: styles.listBackground, + listHoverBackground: styles.listBackground, + listBackground: styles.listBackground, + listFocusForeground: styles.listForeground, + listHoverForeground: styles.listForeground, + listInactiveFocusForeground: styles.listForeground, + listInactiveSelectionForeground: styles.listForeground, + listActiveSelectionForeground: styles.listForeground, + listFocusAndSelectionForeground: styles.listForeground, + listActiveSelectionIconForeground: undefined, + listInactiveSelectionIconForeground: undefined, + }, }, - setRowLineHeight: false, - scrollToActiveElement: true, - filter: options.filter, - overrideStyles: { - listFocusBackground: styles.listBackground, - listInactiveFocusBackground: styles.listBackground, - listActiveSelectionBackground: styles.listBackground, - listFocusAndSelectionBackground: styles.listBackground, - listInactiveSelectionBackground: styles.listBackground, - listHoverBackground: styles.listBackground, - listBackground: styles.listBackground, - listFocusForeground: styles.listForeground, - listHoverForeground: styles.listForeground, - listInactiveFocusForeground: styles.listForeground, - listInactiveSelectionForeground: styles.listForeground, - listActiveSelectionForeground: styles.listForeground, - listFocusAndSelectionForeground: styles.listForeground, - listActiveSelectionIconForeground: undefined, - listInactiveSelectionIconForeground: undefined, - } - } - )); + ), + ); // Create scroll-down button - this._scrollDownButton = this._register(new Button(this._container, { - buttonBackground: asCssVariable(buttonSecondaryBackground), - buttonForeground: asCssVariable(buttonSecondaryForeground), - buttonHoverBackground: asCssVariable(buttonSecondaryHoverBackground), - buttonSecondaryBackground: undefined, - buttonSecondaryForeground: undefined, - buttonSecondaryHoverBackground: undefined, - buttonSeparator: undefined, - supportIcons: true, - })); + this._scrollDownButton = this._register( + new Button(this._container, { + buttonBackground: asCssVariable(buttonSecondaryBackground), + buttonForeground: asCssVariable(buttonSecondaryForeground), + buttonHoverBackground: asCssVariable(buttonSecondaryHoverBackground), + buttonSecondaryBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSeparator: undefined, + supportIcons: true, + }), + ); this._scrollDownButton.element.classList.add('chat-scroll-down'); this._scrollDownButton.label = `$(${Codicon.chevronDown.id})`; this._scrollDownButton.element.style.display = 'none'; // Hidden by default - this._register(this._scrollDownButton.onDidClick(() => { - this.setScrollLock(true); - this.scrollToEnd(); - })); + this._register( + this._scrollDownButton.onDidClick(() => { + this.setScrollLock(true); + this.scrollToEnd(); + }), + ); // Wire up tree events // Handle content height changes (fires high-level event, internal scroll handling) - this._register(this._tree.onDidChangeContentHeight(() => { - this._onDidChangeContentHeight.fire(); - })); + this._register( + this._tree.onDidChangeContentHeight(() => { + this._onDidChangeContentHeight.fire(); + this._scrollbarPromptMarkerController.refreshIfDimensionsChanged(); + }), + ); - this._register(this._tree.onDidFocus(() => { - this._onDidFocus.fire(); - })); + this._register( + this._tree.onDidFocus(() => { + this._onDidFocus.fire(); + }), + ); // Handle focus changes internally (update mostRecentlyFocusedItemIndex) - this._register(this._tree.onDidChangeFocus(() => { - const focused = this.getFocus(); - if (focused && focused.length > 0) { - const focusedItem = focused[0]; - const items = this.getItems(); - const idx = items.findIndex(i => i === focusedItem); - if (idx !== -1) { - this._mostRecentlyFocusedItemIndex = idx; + this._register( + this._tree.onDidChangeFocus(() => { + const focused = this.getFocus(); + if (focused && focused.length > 0) { + const focusedItem = focused[0]; + const items = this.getItems(); + const idx = items.findIndex((i) => i === focusedItem); + if (idx !== -1) { + this._mostRecentlyFocusedItemIndex = idx; + } } - } - })); + this._scrollbarPromptMarkerController.refresh(); + }), + ); // Handle scroll events (fire public event and manage scroll-down button) - this._register(this._tree.onDidScroll((e) => { - this._onDidScroll.fire(e); - this.updateScrollDownButtonVisibility(); - })); + this._register( + this._tree.onDidScroll((e) => { + this._onDidScroll.fire(e); + this.updateScrollDownButtonVisibility(); + this._scrollbarPromptMarkerController.refreshIfDimensionsChanged(); + }), + ); // Set initial at-bottom state (scrollLock defaults to true) this.updateScrollDownButtonVisibility(); // Handle context menu internally - this._register(this._tree.onContextMenu(e => { - this.handleContextMenu(e); - })); + this._register( + this._tree.onContextMenu((e) => { + this.handleContextMenu(e); + }), + ); - this._register(this.configurationService.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration(ChatConfiguration.EditRequests) || e.affectsConfiguration(ChatConfiguration.CheckpointsEnabled)) { - this._settingChangeCounter++; - this.refresh(); - } - })); + this._register( + this.configurationService.onDidChangeConfiguration((e) => { + if ( + e.affectsConfiguration(ChatConfiguration.EditRequests) || + e.affectsConfiguration(ChatConfiguration.CheckpointsEnabled) + ) { + this._settingChangeCounter++; + this.refresh(); + } + }), + ); + + this._scrollbarPromptMarkerController = this._register( + new ChatScrollbarPromptMarkerController( + this, + this.configurationService, + ), + ); + this._scrollbarPromptMarkerController.setEnabled(!!options.scrollbarPromptMarkersEnabled); } //#region Internal event handlers @@ -480,7 +619,9 @@ export class ChatListWidget extends Disposable { /** * Handle context menu events. */ - private handleContextMenu(e: ITreeContextMenuEvent): void { + private handleContextMenu( + e: ITreeContextMenuEvent, + ): void { e.browserEvent.preventDefault(); e.browserEvent.stopPropagation(); @@ -488,12 +629,16 @@ export class ChatListWidget extends Disposable { // Check if the context menu was opened on a KaTeX element const target = e.browserEvent.target as HTMLElement; - const isKatexElement = target.closest(`.${katexContainerClassName}`) !== null; + const isKatexElement = + target.closest(`.${katexContainerClassName}`) !== null; const scopedContextKeyService = this.contextKeyService.createOverlay([ [ChatContextKeys.isResponse.key, isResponseVM(selected)], - [ChatContextKeys.responseIsFiltered.key, isResponseVM(selected) && !!selected.errorDetails?.responseIsFiltered], - [ChatContextKeys.isKatexMathElement.key, isKatexElement] + [ + ChatContextKeys.responseIsFiltered.key, + isResponseVM(selected) && !!selected.errorDetails?.responseIsFiltered, + ], + [ChatContextKeys.isKatexMathElement.key, isKatexElement], ]); this.contextMenuService.showContextMenu({ menuId: MenuId.ChatContext, @@ -525,6 +670,7 @@ export class ChatListWidget extends Disposable { this._tree.setChildren(null, []); this._lastItem = undefined; this._lastItemIdContextKey.set([]); + this._scrollbarPromptMarkerController.refresh(); return; } @@ -532,7 +678,7 @@ export class ChatListWidget extends Disposable { this._lastItem = items.at(-1); this._lastItemIdContextKey.set(this._lastItem ? [this._lastItem.id] : []); - const treeItems: ITreeElement[] = items.map(item => ({ + const treeItems: ITreeElement[] = items.map((item) => ({ element: item, collapsed: false, collapsible: false, @@ -545,17 +691,30 @@ export class ChatListWidget extends Disposable { diffIdentityProvider: { getId: (element) => { // Pending types only have 'id', request/response have 'dataId' - const baseId = (isRequestVM(element) || isResponseVM(element)) ? element.dataId : element.id; - const disablement = (isRequestVM(element) || isResponseVM(element)) ? element.shouldBeRemovedOnSend : undefined; + const baseId = + isRequestVM(element) || isResponseVM(element) + ? element.dataId + : element.id; + const disablement = + isRequestVM(element) || isResponseVM(element) + ? element.shouldBeRemovedOnSend + : undefined; // Per-element editing state: only re-render items whose editing role changed - const isEditTarget = isRequestVM(element) && editing?.id === element.id; - const isBlocked = (isRequestVM(element) || isResponseVM(element)) ? element.shouldBeBlocked.get() : false; - return baseId + + const isEditTarget = + isRequestVM(element) && editing?.id === element.id; + const isBlocked = + isRequestVM(element) || isResponseVM(element) + ? element.shouldBeBlocked.get() + : false; + return ( + baseId + // If a response is in the process of progressive rendering, we need to ensure that it will // be re-rendered so progressive rendering is restarted, even if the model wasn't updated. `${isResponseVM(element) && element.renderData ? `_${this._visibleChangeCount}` : ''}` + // Re-render once content references are loaded - (isResponseVM(element) ? `_${element.contentReferences.length}` : '') + + (isResponseVM(element) + ? `_${element.contentReferences.length}` + : '') + // Re-render if element becomes hidden due to undo/redo `_${disablement ? `${disablement.afterUndoStop || '1'}` : '0'}` + // Re-render the request being edited and requests whose blocked state changed @@ -567,11 +726,15 @@ export class ChatListWidget extends Disposable { `_setting${this._settingChangeCounter}` + // Rerender request if we got new content references in the response // since this may change how we render the corresponding attachments in the request - (isRequestVM(element) && element.contentReferences ? `_${element.contentReferences?.length}` : ''); + (isRequestVM(element) && element.contentReferences + ? `_${element.contentReferences?.length}` + : '') + ); }, - } + }, }); }); + this._scrollbarPromptMarkerController.refresh(); } /** @@ -582,6 +745,14 @@ export class ChatListWidget extends Disposable { this.updateScrollDownButtonVisibility(); } + /** + * Enable or disable scrollbar prompt markers at runtime (e.g. when the + * `chat.scrollbarPromptMarkers.enabled` setting changes). + */ + setScrollbarPromptMarkersEnabled(enabled: boolean): void { + this._scrollbarPromptMarkerController.setEnabled(enabled); + } + /** * Get scroll lock state. */ @@ -623,7 +794,7 @@ export class ChatListWidget extends Disposable { this._tree.rerender(); } - private getItems(): ChatTreeItem[] { + getItems(): ChatTreeItem[] { const items: ChatTreeItem[] = []; const root = this._tree.getNode(null); for (const child of root.children) { @@ -634,7 +805,6 @@ export class ChatListWidget extends Disposable { return items; } - /** * Delegate scroll events from a mouse wheel event to the tree. */ @@ -660,6 +830,18 @@ export class ChatListWidget extends Disposable { } } + getElementTop(element: ChatTreeItem): number { + return this._tree.getElementTop(element); + } + + getElementHeight(element: ChatTreeItem): number { + return this._tree.getElementHeight(element); + } + + getOverviewRulerLayoutInfo(): { parent: HTMLElement; insertBefore: HTMLElement } | undefined { + return this._tree.getOverviewRulerLayoutInfo(); + } + /** * Scroll to reveal an element. */ @@ -700,7 +882,11 @@ export class ChatListWidget extends Disposable { } let focusIndex: number; - if (useMostRecentlyFocusedIndex && this._mostRecentlyFocusedItemIndex >= 0 && this._mostRecentlyFocusedItemIndex < items.length) { + if ( + useMostRecentlyFocusedIndex && + this._mostRecentlyFocusedItemIndex >= 0 && + this._mostRecentlyFocusedItemIndex < items.length + ) { focusIndex = this._mostRecentlyFocusedItemIndex; } else { focusIndex = items.length - 1; @@ -764,7 +950,9 @@ export class ChatListWidget extends Disposable { /** * Get code block info for a response. */ - getCodeBlockInfosForResponse(response: IChatResponseViewModel): IChatCodeBlockInfo[] { + getCodeBlockInfosForResponse( + response: IChatResponseViewModel, + ): IChatCodeBlockInfo[] { return this._renderer.getCodeBlockInfosForResponse(response); } @@ -778,14 +966,18 @@ export class ChatListWidget extends Disposable { /** * Get file tree info for a response. */ - getFileTreeInfosForResponse(response: IChatResponseViewModel): IChatFileTreeInfo[] { + getFileTreeInfosForResponse( + response: IChatResponseViewModel, + ): IChatFileTreeInfo[] { return this._renderer.getFileTreeInfosForResponse(response); } /** * Get the last focused file tree for a response. */ - getLastFocusedFileTreeForResponse(response: IChatResponseViewModel): IChatFileTreeInfo | undefined { + getLastFocusedFileTreeForResponse( + response: IChatResponseViewModel, + ): IChatFileTreeInfo | undefined { return this._renderer.getLastFocusedFileTreeForResponse(response); } @@ -796,12 +988,12 @@ export class ChatListWidget extends Disposable { return this._renderer.editorsInUse(); } - - /** * Get template data for a request ID. */ - getTemplateDataForRequestId(requestId: string | undefined): IChatListItemTemplate | undefined { + getTemplateDataForRequestId( + requestId: string | undefined, + ): IChatListItemTemplate | undefined { if (!requestId) { return undefined; } @@ -838,7 +1030,7 @@ export class ChatListWidget extends Disposable { listFocusAndSelectionForeground: styles.listForeground, listActiveSelectionIconForeground: undefined, listInactiveSelectionIconForeground: undefined, - } + }, }); } @@ -848,16 +1040,21 @@ export class ChatListWidget extends Disposable { setVisible(visible: boolean): void { this._visible = visible; this._renderer.setVisible(visible); + this._scrollbarPromptMarkerController.setVisible(visible); } /** * Layout the list. */ layout(height: number, width: number): void { - this._bodyDimension = new dom.Dimension(width ?? this._container.clientWidth, height); + this._bodyDimension = new dom.Dimension( + width ?? this._container.clientWidth, + height, + ); this.updateLastItemMinHeight(); this._tree.layout(height, width); this._renderer.layout(width ?? this._container.clientWidth); + this._scrollbarPromptMarkerController.layout(); } private _bodyDimension: dom.Dimension | null = null; @@ -870,16 +1067,26 @@ export class ChatListWidget extends Disposable { const contentHeight = this._bodyDimension.height; if (this._renderStyle === 'compact' || this._renderStyle === 'minimal') { - this._container.style.removeProperty('--chat-current-response-min-height'); + this._container.style.removeProperty( + '--chat-current-response-min-height', + ); } else { const secondToLastItem = this._viewModel?.getItems().at(-2); const maxRequestShownHeight = 200; const secondToLastItemHeight = Math.min( - (isRequestVM(secondToLastItem) || isResponseVM(secondToLastItem)) ? - secondToLastItem.currentRenderedHeight ?? 150 : 150, - maxRequestShownHeight); - const lastItemMinHeight = Math.max(contentHeight - (secondToLastItemHeight + 10), 0); - this._container.style.setProperty('--chat-current-response-min-height', lastItemMinHeight + 'px'); + isRequestVM(secondToLastItem) || isResponseVM(secondToLastItem) + ? (secondToLastItem.currentRenderedHeight ?? 150) + : 150, + maxRequestShownHeight, + ); + const lastItemMinHeight = Math.max( + contentHeight - (secondToLastItemHeight + 10), + 0, + ); + this._container.style.setProperty( + '--chat-current-response-min-height', + lastItemMinHeight + 'px', + ); if (lastItemMinHeight !== this._previousLastItemMinHeight) { this._previousLastItemMinHeight = lastItemMinHeight; const lastItem = this._viewModel?.getItems().at(-1); @@ -891,5 +1098,4 @@ export class ChatListWidget extends Disposable { } //#endregion - } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatScrollbarPromptMarkerController.ts b/src/vs/workbench/contrib/chat/browser/widget/chatScrollbarPromptMarkerController.ts new file mode 100644 index 00000000000000..6454895a75fb59 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatScrollbarPromptMarkerController.ts @@ -0,0 +1,602 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { + Disposable, + IDisposable, + MutableDisposable, + toDisposable, +} from '../../../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { + ChatConfiguration, + ChatScrollbarPromptMarkerClickBehavior, +} from '../../common/constants.js'; +import { + IChatRequestViewModel, + IChatResponseViewModel, + isRequestVM, + isResponseVM, +} from '../../common/model/chatViewModel.js'; +import { ChatTreeItem } from '../chat.js'; +import { + applyScrollbarPromptMarkerClickBehavior, + getFocusedScrollbarPromptMarkerId, + getScrollbarPromptMarkerDescriptors, +} from '../actions/chatPromptNavigationActions.js'; + +/** + * The host surface that {@link ChatScrollbarPromptMarkerController} depends on. + * This interface captures the subset of {@link ChatListWidget} methods used by + * the controller, allowing it to be tested in isolation with a fake host. + */ +export interface IChatScrollbarPromptMarkerHost { + readonly renderHeight: number; + readonly scrollHeight: number; + getOverviewRulerLayoutInfo(): { parent: HTMLElement; insertBefore: HTMLElement } | undefined; + getItems(): ChatTreeItem[]; + hasElement(element: ChatTreeItem): boolean; + getElementTop(element: ChatTreeItem): number; + getElementHeight(element: ChatTreeItem): number; + getFocus(): ChatTreeItem[]; + reveal(element: ChatTreeItem, relativeTop?: number): void; + focusItem(item: ChatTreeItem): void; +} + +/** + * Manages the lifecycle, layout, and interaction of scrollbar markers on the + * chat overview ruler. + * + * The controller is responsible for: + * - Computing marker positions from chat item heights and scroll dimensions + * - Rendering marker DOM elements (reusing existing elements across renders + * so CSS transitions can animate position/size changes) + * - Resolving overlapping markers via collision detection and priority sorting + * - Handling pointer/click events on the overview ruler, with full-width + * hit-testing so narrow lane markers are as clickable as the full scrollbar + * - Deferring focus to the target chat row after scroll-induced re-renders settle + */ +export class ChatScrollbarPromptMarkerController extends Disposable { + private readonly container = document.createElement('div'); + private readonly markerById = new Map(); + private readonly targetById = new Map< + string, + IChatRequestViewModel | IChatResponseViewModel + >(); + private readonly parentPointerDownListener = this._register( + new MutableDisposable(), + ); + private readonly parentClickListener = this._register( + new MutableDisposable(), + ); + private readonly parentPointerUpListener = this._register( + new MutableDisposable(), + ); + private readonly parentPointerCancelListener = this._register( + new MutableDisposable(), + ); + private pointerDownListenerParent: HTMLElement | undefined; + private visible = true; + private enabled = true; + private markerActivated = false; + private suppressNextClick = false; + private _lastScrollHeight = -1; + private _lastRenderHeight = -1; + private readonly _focusRetryDisposable = this._register(new MutableDisposable()); + private readonly _clickSuppressionDisposable = this._register(new MutableDisposable()); + + constructor( + private readonly host: IChatScrollbarPromptMarkerHost, + private readonly configurationService: IConfigurationService, + ) { + super(); + + this._register( + toDisposable(() => { + this.cancelPendingFocusRetries(); + this.container.remove(); + }), + ); + this.container.classList.add('chat-scrollbar-prompt-markers'); + // The marker overlay is a mouse-only visual aid. It is hidden from the + // accessibility tree because it has no keyboard interaction path. + // Keyboard users can navigate prompts via the Next/Previous User Prompt + // commands, which are documented in the chat accessibility help dialog. + this.container.setAttribute('aria-hidden', 'true'); + this.container.style.position = 'absolute'; + this.container.style.top = '0'; + this.container.style.bottom = '0'; + this.container.style.pointerEvents = 'none'; + this.container.style.display = 'none'; + } + + setVisible(visible: boolean): void { + this.visible = visible; + if (!visible) { + this.resetGestureState(); + this.cancelPendingFocusRetries(); + } + this.updateContainerVisibility(); + } + + /** + * Enable or disable the marker overlay at runtime (e.g. when the + * `chat.scrollbarPromptMarkers.enabled` setting changes). When disabled, + * the overlay container is hidden and all marker DOM nodes are cleared. + * When re-enabled, the overlay is re-laid-out and markers are refreshed. + */ + setEnabled(enabled: boolean): void { + if (this.enabled === enabled) { + return; + } + this.enabled = enabled; + if (!enabled) { + this.resetGestureState(); + this.cancelPendingFocusRetries(); + this.clearMarkers(); + // Fully detach the overlay and dispose the capture listeners on the + // overview-ruler parent so the feature is a true no-op when disabled. + // Re-enabling via layout() re-attaches the container and listeners. + this.detachOverlay(); + } + this.updateContainerVisibility(); + if (enabled) { + this.layout(); + } + } + + layout(): void { + if (!this.enabled) { + return; + } + + const layoutInfo = this.host.getOverviewRulerLayoutInfo(); + if (!layoutInfo) { + return; + } + + if ( + this.container.parentElement !== layoutInfo.parent || + this.container.nextElementSibling !== layoutInfo.insertBefore + ) { + layoutInfo.parent.insertBefore(this.container, layoutInfo.insertBefore); + } + + const scrollbarWidth = Math.max( + 0, + Math.round(layoutInfo.insertBefore.getBoundingClientRect().width), + ); + this.container.style.right = '0'; + this.container.style.height = `${this.host.renderHeight}px`; + this.container.style.width = `${scrollbarWidth}px`; + if (this.pointerDownListenerParent !== layoutInfo.parent) { + this.pointerDownListenerParent = layoutInfo.parent; + this.parentPointerDownListener.value = dom.addDisposableListener( + layoutInfo.parent, + dom.EventType.POINTER_DOWN, + (event) => this.onOverviewRulerPointerDown(event), + true, + ); + this.parentClickListener.value = dom.addDisposableListener( + layoutInfo.parent, + dom.EventType.CLICK, + (event) => this.onOverviewRulerClick(event), + true, + ); + this.parentPointerUpListener.value = dom.addDisposableListener( + layoutInfo.parent, + dom.EventType.POINTER_UP, + (event) => this.onOverviewRulerPointerUp(event), + true, + ); + this.parentPointerCancelListener.value = dom.addDisposableListener( + layoutInfo.parent, + 'pointercancel', + () => this.onOverviewRulerPointerCancel(), + true, + ); + } + this.updateContainerVisibility(); + this.renderMarkers(); + } + + refresh(): void { + this.renderMarkers(); + } + + /** + * Refreshes markers only when the scroll dimensions (scrollHeight or + * renderHeight) have changed since the last render. This is used for + * scroll events, where the viewport moves but marker geometry — which + * is computed from element positions relative to total scroll height — + * does not change unless virtualization re-measures row heights. + */ + refreshIfDimensionsChanged(): void { + if (this.host.scrollHeight !== this._lastScrollHeight || this.host.renderHeight !== this._lastRenderHeight) { + this.renderMarkers(); + } + } + + private updateContainerVisibility(): void { + const shouldShow = this.visible && this.enabled && this.host.renderHeight > 0; + this.container.style.display = shouldShow ? '' : 'none'; + } + + /** + * Detaches the overlay container from the DOM and disposes the capture + * listeners installed on the overview-ruler parent. Used when the marker + * feature is disabled so it becomes a true no-op (no DOM presence, no + * pointer-event interception). {@link layout} re-attaches both on re-enable. + */ + private detachOverlay(): void { + this.container.remove(); + this.parentPointerDownListener.clear(); + this.parentClickListener.clear(); + this.parentPointerUpListener.clear(); + this.parentPointerCancelListener.clear(); + this.pointerDownListenerParent = undefined; + } + + private cancelPendingFocusRetries(): void { + this._focusRetryDisposable.clear(); + } + + private clearClickSuppression(): void { + this.suppressNextClick = false; + this._clickSuppressionDisposable.clear(); + } + + private resetGestureState(): void { + this.markerActivated = false; + this.clearClickSuppression(); + } + + private scheduleFocusRetry(targetWindow: Window, callback: () => void): IDisposable { + let disposed = false; + let settled = false; + const runOnce = () => { + if (!disposed && !settled) { + settled = true; + callback(); + } + }; + + const requestAnimationFrameFn = targetWindow.requestAnimationFrame?.bind(targetWindow) + ?? globalThis.requestAnimationFrame?.bind(globalThis); + + let frameHandle: number | undefined; + if (typeof requestAnimationFrameFn === 'function') { + frameHandle = requestAnimationFrameFn(runOnce); + } else if (typeof queueMicrotask === 'function') { + queueMicrotask(runOnce); + } else { + Promise.resolve().then(runOnce); + } + + return toDisposable(() => { + disposed = true; + if (typeof frameHandle === 'number') { + if (typeof targetWindow.cancelAnimationFrame === 'function') { + targetWindow.cancelAnimationFrame(frameHandle); + } else if (typeof globalThis.cancelAnimationFrame === 'function') { + globalThis.cancelAnimationFrame(frameHandle); + } + } + }); + } + + private clearMarkers(): void { + for (const [, marker] of this.markerById) { marker.remove(); } + this.markerById.clear(); + this.targetById.clear(); + } + + private renderMarkers(): void { + if (!this.visible || !this.enabled) { + this.updateContainerVisibility(); + return; + } + + if (!this.host.getOverviewRulerLayoutInfo()) { + return; + } + + const scrollHeight = this.host.scrollHeight; + const rulerHeight = this.host.renderHeight; + if (scrollHeight <= 0 || rulerHeight <= 0) { + for (const [, marker] of this.markerById) { marker.remove(); } + this.markerById.clear(); + this.targetById.clear(); + this.updateContainerVisibility(); + return; + } + + const descriptors = getScrollbarPromptMarkerDescriptors( + this.host.getItems(), + ).filter((descriptor) => this.host.hasElement(descriptor.target)); + const activeMarkerId = this.getFocusedMarkerId(); + const markerHeightScale = rulerHeight / scrollHeight; + + const nextMarkerById = new Map(); + const nextTargetById = new Map(); + + const markerLayouts = descriptors.map((descriptor) => { + const elementTop = this.host.getElementTop(descriptor.target); + const elementHeight = this.host.getElementHeight(descriptor.target); + const topRatio = descriptor.topRatio ?? 0; + const heightRatio = descriptor.heightRatio ?? 1; + const scaledTop = (elementTop + (elementHeight * topRatio)) * markerHeightScale; + const scaledHeight = (elementHeight * heightRatio) * markerHeightScale; + const height = Math.min(Math.max(descriptor.minHeight, Math.round(scaledHeight)), rulerHeight); + const top = scaledHeight < descriptor.minHeight + ? scaledTop + (scaledHeight / 2) - (height / 2) + : scaledTop; + return { descriptor, top, height }; + }).sort((a, b) => a.top - b.top || b.descriptor.priority - a.descriptor.priority); + + for (let i = 1; i < markerLayouts.length; i++) { + const previous = markerLayouts[i - 1]; + const current = markerLayouts[i]; + const minimumTop = previous.top + previous.height + 1; + if (current.top < minimumTop) { + current.top = minimumTop; + } + } + + for (let i = markerLayouts.length - 2; i >= 0; i--) { + const current = markerLayouts[i]; + const next = markerLayouts[i + 1]; + const rulerMaxTop = Math.max(rulerHeight - current.height, 0); + current.top = Math.min(current.top, next.top - current.height - 1, rulerMaxTop); + } + + // Second forward pass: the backward pass may have pushed markers up and re-introduced overlaps; + // re-run the forward pass to resolve any such collisions. + for (let i = 1; i < markerLayouts.length; i++) { + const previous = markerLayouts[i - 1]; + const current = markerLayouts[i]; + const minimumTop = previous.top + previous.height + 1; + if (current.top < minimumTop) { + current.top = minimumTop; + } + } + + for (const { descriptor, top, height } of markerLayouts) { + const clampedTop = Math.max(0, Math.min(Math.round(top), Math.max(rulerHeight - height, 0))); + + // Reuse existing marker element so CSS transitions can animate position/size changes + let marker = this.markerById.get(descriptor.id); + if (!marker) { + marker = dom.$('.chat-scrollbar-prompt-marker'); + marker.style.position = 'absolute'; + marker.style.pointerEvents = 'auto'; + marker.style.cursor = 'pointer'; + this.container.appendChild(marker); + } + + switch (descriptor.lane) { + case 'left': + marker.style.left = '0'; + marker.style.right = 'auto'; + marker.style.width = '50%'; + break; + case 'right': + marker.style.left = 'auto'; + marker.style.right = '0'; + marker.style.width = '50%'; + break; + default: + marker.style.left = '0'; + marker.style.right = '0'; + marker.style.width = 'auto'; + break; + } + + marker.dataset.markerId = descriptor.id; + marker.dataset.requestId = descriptor.request.id; + marker.dataset.markerType = descriptor.markerType; + marker.dataset.lane = descriptor.lane; + marker.style.top = `${clampedTop}px`; + marker.style.height = `${height}px`; + marker.style.zIndex = String(descriptor.priority); + marker.className = `chat-scrollbar-prompt-marker chat-scrollbar-prompt-marker-type-${descriptor.markerType} chat-scrollbar-prompt-marker-lane-${descriptor.lane}`; + marker.classList.toggle( + 'active', + descriptor.target.id === activeMarkerId, + ); + + nextMarkerById.set(descriptor.id, marker); + nextTargetById.set(descriptor.id, descriptor.target); + } + + // Remove stale markers that are no longer present + for (const [id, marker] of this.markerById) { + if (!nextMarkerById.has(id)) { + marker.remove(); + } + } + + this.markerById.clear(); + for (const [id, marker] of nextMarkerById) { + this.markerById.set(id, marker); + } + this.targetById.clear(); + for (const [id, target] of nextTargetById) { + this.targetById.set(id, target); + } + this._lastScrollHeight = scrollHeight; + this._lastRenderHeight = rulerHeight; + this.updateContainerVisibility(); + } + + private onOverviewRulerPointerDown(event: PointerEvent): void { + // Only the primary button activates markers for mouse pointers; touch/pen + // always go through since they have no button semantics. + if (event.pointerType === 'mouse' && event.button !== 0) { + return; + } + + const target = this.getTargetAtPoint(event.clientX, event.clientY); + if (!target) { + return; + } + + this.resetGestureState(); + this.markerActivated = true; + event.preventDefault(); + event.stopPropagation(); + this.revealItem(target); + } + + private onOverviewRulerPointerUp(event: PointerEvent): void { + if (!this.markerActivated) { + return; + } + // Suppress pointerup so the scrollbar doesn't process it and steal focus, + // then swallow the follow-on click if it arrives. + this.markerActivated = false; + this.suppressNextClick = true; + this._clickSuppressionDisposable.value = this.scheduleFocusRetry(dom.getWindow(this.container), () => { + this.suppressNextClick = false; + }); + event.preventDefault(); + event.stopPropagation(); + } + + private onOverviewRulerPointerCancel(): void { + // The gesture was interrupted (e.g. OS scroll/zoom takeover, pointer left + // the page). Drop any armed suppression so a later unrelated pointerup or + // click is not swallowed. + this.resetGestureState(); + } + + private onOverviewRulerClick(event: MouseEvent): void { + if (!this.suppressNextClick) { + return; + } + // Swallow the click that follows pointerdown so the scrollbar doesn't + // process it and steal focus from the target request. + this.clearClickSuppression(); + event.preventDefault(); + event.stopPropagation(); + } + + /** + * Resolves which chat row a pointer event should navigate to, using + * full-width Y-axis hit-testing against all rendered markers. + * + * Unlike standard DOM hit-testing (which checks each marker's actual rect), + * this method matches any marker whose Y range contains the click — even if + * the click landed outside the marker's narrow lane. This makes 50%-width + * lane markers as clickable as the full scrollbar width. + * + * When multiple markers overlap at the same Y position, priority is: + * right-lane (prompt) > left-lane (ask-question) > full-lane (file-change/error). + */ + private getTargetAtPoint( + clientX: number, + clientY: number, + ): IChatRequestViewModel | IChatResponseViewModel | undefined { + if (!this.visible || this.container.style.display === 'none') { + return undefined; + } + + // Hit-test against the full container width (not just the marker's narrow lane), + // so that clicking anywhere at a marker's Y position activates it — matching how + // Monaco's overview ruler handles clicks. When multiple markers overlap at the + // same Y, prefer right-lane (prompt) markers, then left-lane, then full-lane. + const containerRect = this.container.getBoundingClientRect(); + if ( + clientX < containerRect.left || + clientX > containerRect.right || + clientY < containerRect.top || + clientY > containerRect.bottom + ) { + return undefined; + } + + const candidates: Array<{ id: string; lane: string }> = []; + for (const [id, marker] of this.markerById) { + // Use cached positions from the last renderMarkers pass (stored as + // pixel strings in style.top/style.height) to avoid forcing a + // synchronous layout read per marker during hit-testing. + const top = parseFloat(marker.style.top); + const height = parseFloat(marker.style.height); + if (Number.isNaN(top) || Number.isNaN(height)) { + continue; + } + const markerTop = containerRect.top + top; + const markerBottom = markerTop + height; + if (clientY < markerTop || clientY > markerBottom) { + continue; + } + const lane = marker.dataset.lane ?? 'full'; + candidates.push({ id, lane }); + } + + if (candidates.length === 0) { + return undefined; + } + + // Prefer right-lane (prompt) > left-lane > full-lane + const lanePriority: Record = { right: 0, left: 1, full: 2 }; + candidates.sort((a, b) => (lanePriority[a.lane] ?? 3) - (lanePriority[b.lane] ?? 3)); + + return this.targetById.get(candidates[0].id); + } + + private getFocusedMarkerId(): string | undefined { + const focused = this.host.getFocus()[0]; + if (!focused || (!isRequestVM(focused) && !isResponseVM(focused))) { + return undefined; + } + + return getFocusedScrollbarPromptMarkerId(focused); + } + + /** + * Reveals and optionally focuses the target chat row. Focus is deferred + * across multiple animation frames because revealing a row in a long chat + * triggers dynamic height re-measurement in the virtualized tree, which + * can steal focus during the re-render cycle. The focus is retried until + * the target element is available in the tree or a maximum attempt count + * is reached. + */ + private revealItem(item: IChatRequestViewModel | IChatResponseViewModel): void { + const behavior = + this.configurationService.getValue( + ChatConfiguration.ScrollbarPromptMarkerClickBehavior, + ); + + // For the Reveal behavior, delegate entirely to the shared helper so + // there is a single source of truth for click behavior. For + // RevealAndFocus, reveal here but defer focusItem below, because + // revealing a row in a long chat triggers dynamic height re-measurement + // in the virtualized tree, which can steal focus during the re-render + // cycle. The focus is retried across animation frames until the target + // element is available in the tree or a maximum attempt count is reached. + if (behavior === ChatScrollbarPromptMarkerClickBehavior.Reveal) { + applyScrollbarPromptMarkerClickBehavior(this.host, item, behavior); + return; + } + + this.host.reveal(item); + const targetWindow = dom.getWindow(this.container); + let attempts = 0; + const maxAttempts = 10; + const tryFocus = () => { + if (this.host.hasElement(item)) { + this.host.focusItem(item); + return; + } + attempts++; + if (attempts < maxAttempts) { + this._focusRetryDisposable.value = this.scheduleFocusRetry(targetWindow, tryFocus); + } + }; + this._focusRetryDisposable.value = this.scheduleFocusRetry(targetWindow, tryFocus); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index d8efe19aefeb0a..cef6f401b79be2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -493,6 +493,12 @@ export class ChatWidget extends Disposable implements IChatWidget { if (e.affectsConfiguration(ChatConfiguration.ProgressBorder)) { this.updateWorkingProgressBorder(); } + if (e.affectsConfiguration(ChatConfiguration.ScrollbarPromptMarkersEnabled)) { + this.listWidget.setScrollbarPromptMarkersEnabled( + !isInlineChat(this) && !isQuickChat(this) + && this.configurationService.getValue(ChatConfiguration.ScrollbarPromptMarkersEnabled) + ); + } })); this._register(this.accessibilityService.onDidChangeReducedMotion(() => { @@ -1608,6 +1614,8 @@ export class ChatWidget extends Disposable implements IChatWidget { location: this.location, getCurrentLanguageModelId: () => this.input.currentLanguageModel, getCurrentModeInfo: () => this.input.currentModeInfo, + scrollbarPromptMarkersEnabled: !isInlineChat(this) && !isQuickChat(this) + && this.configurationService.getValue(ChatConfiguration.ScrollbarPromptMarkersEnabled), } )); diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 3920530d9e2bfc..e9af936e08c759 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -4483,6 +4483,82 @@ have to be updated for changes to the rules above, or to support more deeply nes color: var(--vscode-charts-green, #3fb950) !important; } +.chat-scrollbar-prompt-markers { + overflow: hidden; + /* Scrollbar .visible layer uses z-index 11; keep markers above it so marker clicks are not swallowed by the scrollbar. */ + /* The container itself is non-interactive (pointer-events: none is set inline by the controller); only the child marker elements receive pointer events. */ + z-index: 12; +} + +.chat-scrollbar-prompt-markers > .chat-scrollbar-prompt-marker { + position: absolute; + box-sizing: border-box; + left: 0; + right: 0; + min-height: var(--vscode-spacing-size40); + border-radius: 0; + background-color: var(--vscode-scrollbarSlider-background); + opacity: 0.85; + pointer-events: auto; + transition: none; +} + +.monaco-workbench.monaco-enable-motion .chat-scrollbar-prompt-markers > .chat-scrollbar-prompt-marker { + transition: top 0.05s ease-out, height 0.05s ease-out; +} + +.chat-scrollbar-prompt-markers > .chat-scrollbar-prompt-marker:hover { + opacity: 1; +} + +.chat-scrollbar-prompt-markers > .chat-scrollbar-prompt-marker.active { + opacity: 1; + outline: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder, var(--vscode-contrastBorder)); +} + +.chat-scrollbar-prompt-markers > .chat-scrollbar-prompt-marker.chat-scrollbar-prompt-marker-lane-left { + left: 0; + right: auto; + width: 50%; +} + +.chat-scrollbar-prompt-markers > .chat-scrollbar-prompt-marker.chat-scrollbar-prompt-marker-lane-right { + left: auto; + right: 0; + width: 50%; +} + +.chat-scrollbar-prompt-markers > .chat-scrollbar-prompt-marker.chat-scrollbar-prompt-marker-lane-full { + left: 0; + right: 0; + width: auto; +} + +.chat-scrollbar-prompt-markers > .chat-scrollbar-prompt-marker.chat-scrollbar-prompt-marker-type-prompt { + background-color: var(--vscode-charts-purple, #a371f7); +} + +.chat-scrollbar-prompt-markers > .chat-scrollbar-prompt-marker.chat-scrollbar-prompt-marker-type-askQuestion { + background-color: var(--vscode-charts-yellow, #e5c07b); +} + +.chat-scrollbar-prompt-markers > .chat-scrollbar-prompt-marker.chat-scrollbar-prompt-marker-type-fileChange { + background-color: var(--vscode-charts-green, #3fb950); +} + +.chat-scrollbar-prompt-markers > .chat-scrollbar-prompt-marker.chat-scrollbar-prompt-marker-type-compaction { + background-color: var(--vscode-charts-yellow, #e5c07b); +} + +.chat-scrollbar-prompt-markers > .chat-scrollbar-prompt-marker.chat-scrollbar-prompt-marker-type-error { + background-color: var(--vscode-charts-red, #f85149); +} + +.hc-black .chat-scrollbar-prompt-markers > .chat-scrollbar-prompt-marker, +.hc-light .chat-scrollbar-prompt-markers > .chat-scrollbar-prompt-marker { + border: var(--vscode-strokeThickness) solid var(--vscode-contrastBorder); +} + .monaco-reduce-motion .action-item.chat-restore-checkpoint-item.confirming .action-label:first-child { animation: none; background: none; diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 751d39980801d5..93e7112288a5e1 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -36,6 +36,8 @@ export enum ChatConfiguration { RepoInfoEnabled = 'chat.repoInfo.enabled', EditRequests = 'chat.editRequests', InlineReferencesStyle = 'chat.inlineReferences.style', + ScrollbarPromptMarkersEnabled = 'chat.scrollbarPromptMarkers.enabled', + ScrollbarPromptMarkerClickBehavior = 'chat.scrollbarPromptMarkers.clickBehavior', AutoReply = 'chat.autoReply', GlobalAutoApprove = 'chat.tools.global.autoApprove', AutoApproveEdits = 'chat.tools.edits.autoApprove', @@ -182,6 +184,11 @@ export enum ChatNotificationMode { Always = 'always', } +export enum ChatScrollbarPromptMarkerClickBehavior { + RevealAndFocus = 'revealAndFocus', + Reveal = 'reveal', +} + export type RawChatParticipantLocation = 'panel' | 'terminal' | 'notebook' | 'editing-session'; export enum ChatAgentLocation { diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index 1e88e91aabe4b5..f9a2c948c7d1c6 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -16,7 +16,7 @@ import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js import { ChatAgentVoteDirection, ChatRequestQueueKind, IChatCodeCitation, IChatContentReference, IChatDisabledClaudeHooksPart, IChatFollowup, IChatMcpServersStarting, IChatPlanReview, IChatProgressMessage, IChatQuestionCarousel, IChatResponseErrorDetails, IChatTask, IChatUsage, IChatUsedContext } from '../chatService/chatService.js'; import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameService, IChatAgentResult } from '../participants/chatAgents.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; -import { IChatModel, IChatProgressRenderableResponseContent, IChatRequestDisablement, IChatRequestModel, IChatResponseModel, IChatTextEditGroup, IResponse } from './chatModel.js'; +import { IChatAgentEditedFileEvent, IChatModel, IChatProgressRenderableResponseContent, IChatRequestDisablement, IChatRequestModel, IChatResponseModel, IChatTextEditGroup, IResponse } from './chatModel.js'; import { ChatStreamStatsTracker, IChatStreamStats } from './chatStreamStats.js'; import { countWords } from './chatWordCounter.js'; @@ -96,6 +96,7 @@ export interface IChatRequestViewModel { readonly attachedContext?: readonly IChatRequestVariableEntry[]; readonly modelId?: string; readonly timestamp: number; + readonly editedFileEvents?: readonly IChatAgentEditedFileEvent[]; /** The kind of pending request, or undefined if not pending */ readonly pendingKind?: ChatRequestQueueKind; readonly isSystemInitiated?: boolean; @@ -504,6 +505,10 @@ export class ChatRequestViewModel implements IChatRequestViewModel { return this._model.timestamp; } + get editedFileEvents() { + return this._model.editedFileEvents; + } + get pendingKind() { return this._pendingKind; } diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatPromptNavigationActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatPromptNavigationActions.test.ts new file mode 100644 index 00000000000000..47ef56f6689f57 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatPromptNavigationActions.test.ts @@ -0,0 +1,480 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ChatScrollbarPromptMarkerClickBehavior } from '../../../common/constants.js'; +import { IChatRequestViewModel, IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; +import { applyScrollbarPromptMarkerClickBehavior, ChatScrollbarPromptMarkerLane, ChatScrollbarPromptMarkerType, getFocusedScrollbarPromptMarkerId, getFocusedScrollbarPromptMarkerRequestId, getScrollbarPromptMarkerDescriptors } from '../../../browser/actions/chatPromptNavigationActions.js'; + +suite('Chat scrollbar prompt marker helpers', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function request(id: string, attempt: number, messageText: string, timestamp: number, options?: { isSystemInitiated?: boolean; slashCommandName?: string }): IChatRequestViewModel { + return { + id, + sessionResource: undefined as never, + dataId: id, + username: 'User', + message: undefined as never, + messageText, + attempt, + variables: [], + currentRenderedHeight: undefined, + isComplete: true, + isCompleteAddedRequest: false, + agentOrSlashCommandDetected: false, + shouldBeRemovedOnSend: undefined as never, + shouldBeBlocked: undefined as never, + timestamp, + editedFileEvents: undefined, + isSystemInitiated: options?.isSystemInitiated, + slashCommand: options?.slashCommandName ? { name: options.slashCommandName } as never : undefined, + } as IChatRequestViewModel; + } + + function response(requestId: string, options?: { errorDetails?: unknown; parts?: unknown[]; slashCommandName?: string }): IChatResponseViewModel { + return { + id: `${requestId}-response`, + sessionResource: undefined as never, + model: { + entireResponse: { + value: options?.parts ?? [], + }, + slashCommand: options?.slashCommandName ? { name: options.slashCommandName } as never : undefined, + } as never, + dataId: `${requestId}-response`, + session: undefined as never, + username: 'Assistant', + agentOrSlashCommandDetected: false, + response: undefined as never, + usedContext: undefined, + contentReferences: [], + codeCitations: [], + progressMessages: [], + isComplete: true, + isCanceled: false, + isStale: false, + vote: undefined, + requestId, + replyFollowups: undefined, + errorDetails: options?.errorDetails, + result: undefined, + contentUpdateTimings: undefined, + confirmationAdjustedTimestamp: undefined as never, + usageObs: undefined as never, + completionTokenCountObs: undefined as never, + isCompleteAddedRequest: false, + currentRenderedHeight: undefined, + setVote: () => { }, + setEditApplied: () => { }, + vulnerabilitiesListExpanded: false, + shouldBeRemovedOnSend: undefined as never, + shouldBeBlocked: undefined as never, + } as IChatResponseViewModel; + } + + test('getScrollbarPromptMarkerDescriptors keeps the latest logical prompt and drops system initiated requests', () => { + const items = [ + request('request-1', 0, 'hello', 1), + response('request-1'), + request('request-2', 1, 'hello', 2), + request('request-3', 0, 'system', 3, { isSystemInitiated: true }), + request('request-4', 0, 'world', 4), + ]; + + const descriptors = getScrollbarPromptMarkerDescriptors(items).filter(d => d.target === d.request); + assert.deepStrictEqual(descriptors.map(d => d.request.id), ['request-2', 'request-4']); + }); + + test('getScrollbarPromptMarkerDescriptors assigns taxonomy lanes and types', () => { + const items = [ + request('request-1', 0, 'Can you help me?', 1), + response('request-1', { parts: [{ kind: 'questionCarousel', isUsed: false }] }), + request('request-2', 0, 'Please update the parser', 2), + response('request-2', { parts: [{ kind: 'textEditGroup', edits: [], done: true, uri: undefined as never }] }), + request('request-3', 0, 'Summarizing the conversation', 3, { slashCommandName: 'compact' }), + request('request-4', 0, 'Fix the crash', 4), + response('request-4', { parts: [{ kind: 'externalEdit' }] }), + request('request-5', 0, 'Oops', 5), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + assert.deepStrictEqual(descriptors.map(descriptor => ({ + id: descriptor.id, + targetId: descriptor.target.id, + markerType: descriptor.markerType, + lane: descriptor.lane, + priority: descriptor.priority, + })), [ + { id: 'request-1', targetId: 'request-1', markerType: ChatScrollbarPromptMarkerType.Prompt, lane: ChatScrollbarPromptMarkerLane.Right, priority: 60 }, + { id: 'request-1-response', targetId: 'request-1-response', markerType: ChatScrollbarPromptMarkerType.AskQuestion, lane: ChatScrollbarPromptMarkerLane.Left, priority: 70 }, + { id: 'request-2', targetId: 'request-2', markerType: ChatScrollbarPromptMarkerType.Prompt, lane: ChatScrollbarPromptMarkerLane.Right, priority: 60 }, + { id: 'request-2-response', targetId: 'request-2-response', markerType: ChatScrollbarPromptMarkerType.FileChange, lane: ChatScrollbarPromptMarkerLane.Full, priority: 80 }, + { id: 'request-3', targetId: 'request-3', markerType: ChatScrollbarPromptMarkerType.Compaction, lane: ChatScrollbarPromptMarkerLane.Full, priority: 90 }, + { id: 'request-4', targetId: 'request-4', markerType: ChatScrollbarPromptMarkerType.Prompt, lane: ChatScrollbarPromptMarkerLane.Right, priority: 60 }, + { id: 'request-4-response', targetId: 'request-4-response', markerType: ChatScrollbarPromptMarkerType.FileChange, lane: ChatScrollbarPromptMarkerLane.Full, priority: 80 }, + { id: 'request-5', targetId: 'request-5', markerType: ChatScrollbarPromptMarkerType.Prompt, lane: ChatScrollbarPromptMarkerLane.Right, priority: 60 }, + ]); + }); + + test('getScrollbarPromptMarkerDescriptors uses the response error state for error markers', () => { + const errorResponse = response('request-6', { errorDetails: { message: 'boom' } as never }); + const items = [ + request('request-6', 0, 'The agent failed', 6), + errorResponse, + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + assert.deepStrictEqual(descriptors.map(descriptor => ({ id: descriptor.id, markerType: descriptor.markerType })), [ + { id: 'request-6', markerType: ChatScrollbarPromptMarkerType.Prompt }, + { id: 'request-6-response', markerType: ChatScrollbarPromptMarkerType.Error }, + ]); + assert.strictEqual(descriptors[1].lane, ChatScrollbarPromptMarkerLane.Full); + assert.strictEqual(descriptors[1].priority, 100); + }); + + test('getScrollbarPromptMarkerDescriptors does not infer ask questions, file changes, or compaction from message text alone', () => { + const items = [ + request('request-1', 0, 'Can you help me?', 1), + response('request-1'), + request('request-2', 0, 'Please update the parser', 2), + response('request-2'), + request('request-3', 0, 'Summarizing the conversation', 3, { isSystemInitiated: true }), + response('request-3'), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + assert.deepStrictEqual(descriptors.map(descriptor => ({ id: descriptor.id, markerType: descriptor.markerType })), [ + { id: 'request-1', markerType: ChatScrollbarPromptMarkerType.Prompt }, + { id: 'request-2', markerType: ChatScrollbarPromptMarkerType.Prompt }, + ]); + }); + + test('getScrollbarPromptMarkerDescriptors keeps prompt and file-change markers distinct for a create-file flow', () => { + const items = [ + request('request-1', 0, 'create a hello world file', 1), + response('request-1', { parts: [{ kind: 'externalEdit' }] }), + request('request-2', 0, 'what is the "reader\'s digest" version of the holy bible?', 2), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + assert.deepStrictEqual(descriptors.map(descriptor => ({ + id: descriptor.id, + targetId: descriptor.target.id, + markerType: descriptor.markerType, + lane: descriptor.lane, + })), [ + { id: 'request-1', targetId: 'request-1', markerType: ChatScrollbarPromptMarkerType.Prompt, lane: ChatScrollbarPromptMarkerLane.Right }, + { id: 'request-1-response', targetId: 'request-1-response', markerType: ChatScrollbarPromptMarkerType.FileChange, lane: ChatScrollbarPromptMarkerLane.Full }, + { id: 'request-2', targetId: 'request-2', markerType: ChatScrollbarPromptMarkerType.Prompt, lane: ChatScrollbarPromptMarkerLane.Right }, + ]); + }); + + test('getScrollbarPromptMarkerDescriptors treats editedFileEvents as a file-change response signal', () => { + const items = [ + { ...request('request-1', 0, 'create a hello world file', 1), editedFileEvents: [{ uri: undefined as never, eventKind: 1 }] }, + response('request-1'), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + assert.deepStrictEqual(descriptors.map(descriptor => ({ id: descriptor.id, markerType: descriptor.markerType })), [ + { id: 'request-1', markerType: ChatScrollbarPromptMarkerType.Prompt }, + { id: 'request-1-response', markerType: ChatScrollbarPromptMarkerType.FileChange }, + ]); + }); + + test('getScrollbarPromptMarkerDescriptors groups multiple edit parts by edit tool invocation', () => { + const items = [ + request('request-1', 0, 'make several edits', 1), + response('request-1', { + parts: [ + { kind: 'toolInvocationSerialized', toolId: 'copilot_createFile' }, + { kind: 'textEditGroup', edits: [], done: true, uri: undefined as never }, + { kind: 'toolInvocationSerialized', toolId: 'copilot_createFile' }, + { kind: 'textEditGroup', edits: [], done: true, uri: undefined as never }, + { kind: 'toolInvocationSerialized', toolId: 'copilot_multiReplaceString' }, + { kind: 'textEditGroup', edits: [], done: true, uri: undefined as never }, + { kind: 'undoStop' }, + { kind: 'codeblockUri' }, + { kind: 'textEditGroup', edits: [], done: true, uri: undefined as never }, + { kind: 'undoStop' }, + { kind: 'codeblockUri' }, + { kind: 'textEditGroup', edits: [], done: true, uri: undefined as never }, + { kind: 'toolInvocationSerialized', toolId: 'copilot_replaceString' }, + { kind: 'textEditGroup', edits: [], done: true, uri: undefined as never }, + ], + }), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + const fileChangeDescriptors = descriptors.filter(descriptor => descriptor.markerType === ChatScrollbarPromptMarkerType.FileChange); + + assert.deepStrictEqual(fileChangeDescriptors.map(descriptor => descriptor.id), [ + 'request-1-response#fileChange0', + 'request-1-response#fileChange1', + 'request-1-response#fileChange2', + 'request-1-response#fileChange3', + ]); + assert.deepStrictEqual(fileChangeDescriptors.map(descriptor => descriptor.topRatio), [ + 0 / 14, + 2 / 14, + 4 / 14, + 12 / 14, + ]); + assert.deepStrictEqual(fileChangeDescriptors.map(descriptor => descriptor.heightRatio), [ + 2 / 14, + 2 / 14, + 8 / 14, + 2 / 14, + ]); + }); + + test('getFocusedScrollbarPromptMarkerRequestId maps request and response focus to the request id', () => { + assert.strictEqual(getFocusedScrollbarPromptMarkerRequestId(request('request-1', 0, 'hello', 1)), 'request-1'); + assert.strictEqual(getFocusedScrollbarPromptMarkerRequestId(response('request-2')), 'request-2'); + assert.strictEqual(getFocusedScrollbarPromptMarkerRequestId(undefined), undefined); + }); + + test('applyScrollbarPromptMarkerClickBehavior reveals or reveals and focuses', () => { + const calls: string[] = []; + const target = { + reveal: (item: IChatRequestViewModel) => calls.push(`reveal:${item.id}`), + focusItem: (item: IChatRequestViewModel) => calls.push(`focus:${item.id}`), + }; + + const item = request('request-1', 0, 'hello', 1); + + applyScrollbarPromptMarkerClickBehavior(target, item, ChatScrollbarPromptMarkerClickBehavior.RevealAndFocus); + assert.deepStrictEqual(calls, ['reveal:request-1', 'focus:request-1']); + + calls.length = 0; + applyScrollbarPromptMarkerClickBehavior(target, item, ChatScrollbarPromptMarkerClickBehavior.Reveal); + assert.deepStrictEqual(calls, ['reveal:request-1']); + }); + + test('getScrollbarPromptMarkerDescriptors returns an empty array for empty input', () => { + assert.deepStrictEqual(getScrollbarPromptMarkerDescriptors([]), []); + }); + + test('getScrollbarPromptMarkerDescriptors emits a prompt marker for requests with no paired response', () => { + const items = [ + request('request-1', 0, 'hello', 1), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + assert.deepStrictEqual(descriptors.map(descriptor => ({ id: descriptor.id, markerType: descriptor.markerType })), [ + { id: 'request-1', markerType: ChatScrollbarPromptMarkerType.Prompt }, + ]); + }); + + test('getScrollbarPromptMarkerDescriptors keeps only the latest attempt when message text is duplicated', () => { + const items = [ + request('request-1', 0, 'hello', 1), + request('request-2', 1, 'hello', 2), + request('request-3', 0, 'hello', 3), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + // request-2 survives (highest attempt wins; timestamp is only a tie-break for equal attempts) + assert.deepStrictEqual(descriptors.map(descriptor => descriptor.id), ['request-2']); + }); + + test('getScrollbarPromptMarkerDescriptors tie-breaks on timestamp when attempt is equal', () => { + const items = [ + request('request-1', 0, 'hello', 1), + request('request-2', 0, 'hello', 2), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + // Both have attempt=0, so the later timestamp wins + assert.deepStrictEqual(descriptors.map(descriptor => descriptor.id), ['request-2']); + }); + + test('getScrollbarPromptMarkerDescriptors deduplicates compaction requests by id, not message text', () => { + const items = [ + request('request-1', 0, 'compact', 1, { slashCommandName: 'compact' }), + request('request-2', 0, 'compact', 2, { slashCommandName: 'compact' }), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + // Both survive because compaction deduplicates by id, not message text + assert.deepStrictEqual( + descriptors.map(descriptor => ({ id: descriptor.id, markerType: descriptor.markerType })), + [ + { id: 'request-1', markerType: ChatScrollbarPromptMarkerType.Compaction }, + { id: 'request-2', markerType: ChatScrollbarPromptMarkerType.Compaction }, + ], + ); + }); + + test('getScrollbarPromptMarkerDescriptors keeps system-initiated compaction requests', () => { + const items = [ + request('request-1', 0, 'compact', 1, { isSystemInitiated: true, slashCommandName: 'compact' }), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + assert.deepStrictEqual(descriptors.map(descriptor => ({ id: descriptor.id, markerType: descriptor.markerType })), [ + { id: 'request-1', markerType: ChatScrollbarPromptMarkerType.Compaction }, + ]); + }); + + test('getScrollbarPromptMarkerDescriptors classifies a response with errorDetails as Error even when it also has ask-question and file-change parts', () => { + const items = [ + request('request-1', 0, 'help', 1), + response('request-1', { + errorDetails: { message: 'boom' } as never, + parts: [ + { kind: 'toolInvocationSerialized', toolId: 'copilot_askQuestions' }, + { kind: 'textEditGroup', edits: [], done: true, uri: undefined as never }, + ], + }), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + assert.deepStrictEqual(descriptors.map(descriptor => ({ id: descriptor.id, markerType: descriptor.markerType })), [ + { id: 'request-1', markerType: ChatScrollbarPromptMarkerType.Prompt }, + { id: 'request-1-response', markerType: ChatScrollbarPromptMarkerType.Error }, + ]); + }); + + test('getScrollbarPromptMarkerDescriptors classifies a response with both ask-question and file-change parts as AskQuestion', () => { + const items = [ + request('request-1', 0, 'help', 1), + response('request-1', { + parts: [ + { kind: 'toolInvocationSerialized', toolId: 'copilot_askQuestions' }, + { kind: 'textEditGroup', edits: [], done: true, uri: undefined as never }, + ], + }), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + assert.deepStrictEqual(descriptors.map(descriptor => ({ id: descriptor.id, markerType: descriptor.markerType })), [ + { id: 'request-1', markerType: ChatScrollbarPromptMarkerType.Prompt }, + { id: 'request-1-response', markerType: ChatScrollbarPromptMarkerType.AskQuestion }, + ]); + }); + + test('getScrollbarPromptMarkerDescriptors emits a FileChange marker targeting the request when editedFileEvents is set and response is missing', () => { + const items = [ + { ...request('request-1', 0, 'create a file', 1), editedFileEvents: [{ uri: undefined as never, eventKind: 1 }] }, + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + assert.deepStrictEqual(descriptors.map(descriptor => ({ + id: descriptor.id, + targetId: descriptor.target.id, + markerType: descriptor.markerType, + })), [ + { id: 'request-1', targetId: 'request-1', markerType: ChatScrollbarPromptMarkerType.Prompt }, + { id: 'request-1-fileChange', targetId: 'request-1', markerType: ChatScrollbarPromptMarkerType.FileChange }, + ]); + }); + + test('getScrollbarPromptMarkerDescriptors emits a FileChange marker targeting the response when editedFileEvents is set and response has no edit parts', () => { + const items = [ + { ...request('request-1', 0, 'create a file', 1), editedFileEvents: [{ uri: undefined as never, eventKind: 1 }] }, + response('request-1'), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + assert.deepStrictEqual(descriptors.map(descriptor => ({ + id: descriptor.id, + targetId: descriptor.target.id, + markerType: descriptor.markerType, + })), [ + { id: 'request-1', targetId: 'request-1', markerType: ChatScrollbarPromptMarkerType.Prompt }, + { id: 'request-1-response', targetId: 'request-1-response', markerType: ChatScrollbarPromptMarkerType.FileChange }, + ]); + }); + + test('getScrollbarPromptMarkerDescriptors uses the response id (no #fileChangeN suffix) for a single file-change response', () => { + const items = [ + request('request-1', 0, 'make an edit', 1), + response('request-1', { + parts: [ + { kind: 'toolInvocationSerialized', toolId: 'copilot_createFile' }, + { kind: 'textEditGroup', edits: [], done: true, uri: undefined as never }, + ], + }), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + const fileChangeDescriptors = descriptors.filter(descriptor => descriptor.markerType === ChatScrollbarPromptMarkerType.FileChange); + + assert.deepStrictEqual(fileChangeDescriptors.map(descriptor => descriptor.id), ['request-1-response']); + }); + + test('getScrollbarPromptMarkerDescriptors computes topRatio and heightRatio with the 1/parts.length floor for multi-cluster responses', () => { + const parts = [ + { kind: 'toolInvocationSerialized', toolId: 'copilot_createFile' }, + { kind: 'textEditGroup', edits: [], done: true, uri: undefined as never }, + { kind: 'toolInvocationSerialized', toolId: 'copilot_createFile' }, + { kind: 'textEditGroup', edits: [], done: true, uri: undefined as never }, + ]; + const items = [ + request('request-1', 0, 'make edits', 1), + response('request-1', { parts }), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + const fileChangeDescriptors = descriptors.filter(descriptor => descriptor.markerType === ChatScrollbarPromptMarkerType.FileChange); + + // Each cluster spans 2 parts out of 4, so heightRatio = 2/4 = 0.5 (above the 1/4 floor) + assert.deepStrictEqual(fileChangeDescriptors.map(descriptor => descriptor.topRatio), [0 / 4, 2 / 4]); + assert.deepStrictEqual(fileChangeDescriptors.map(descriptor => descriptor.heightRatio), [2 / 4, 2 / 4]); + }); + + test('getScrollbarPromptMarkerDescriptors enforces the 1/parts.length floor on heightRatio for a single-part edit cluster', () => { + const parts = [ + { kind: 'textEditGroup', edits: [], done: true, uri: undefined as never }, + ]; + const items = [ + request('request-1', 0, 'make an edit', 1), + response('request-1', { parts }), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + const fileChangeDescriptors = descriptors.filter(descriptor => descriptor.markerType === ChatScrollbarPromptMarkerType.FileChange); + + // Single part: heightRatio = max(1/1, 1/1) = 1, topRatio = 0/1 = 0 + assert.deepStrictEqual(fileChangeDescriptors.map(descriptor => descriptor.topRatio), [0]); + assert.deepStrictEqual(fileChangeDescriptors.map(descriptor => descriptor.heightRatio), [1]); + }); + + test('getScrollbarPromptMarkerDescriptors always sets minHeight to 4 on every emitted descriptor', () => { + const items = [ + request('request-1', 0, 'hello', 1), + response('request-1', { parts: [{ kind: 'questionCarousel', isUsed: false }] }), + request('request-2', 0, 'compact', 2, { slashCommandName: 'compact' }), + request('request-3', 0, 'fail', 3), + response('request-3', { errorDetails: { message: 'boom' } as never }), + request('request-4', 0, 'edit', 4), + response('request-4', { parts: [{ kind: 'externalEdit' }] }), + ]; + const descriptors = getScrollbarPromptMarkerDescriptors(items); + + assert.deepStrictEqual(descriptors.map(descriptor => descriptor.minHeight), descriptors.map(() => 4)); + }); + + test('getFocusedScrollbarPromptMarkerId returns the response id for a response, not the request id', () => { + const req = request('request-1', 0, 'hello', 1); + const res = response('request-1'); + + assert.strictEqual(getFocusedScrollbarPromptMarkerId(req), 'request-1'); + assert.strictEqual(getFocusedScrollbarPromptMarkerId(res), 'request-1-response'); + assert.strictEqual(getFocusedScrollbarPromptMarkerId(undefined), undefined); + }); + + test('applyScrollbarPromptMarkerClickBehavior with Reveal only calls reveal and never focusItem', () => { + const calls: string[] = []; + const target = { + reveal: (item: IChatRequestViewModel) => calls.push(`reveal:${item.id}`), + focusItem: (item: IChatRequestViewModel) => calls.push(`focus:${item.id}`), + }; + + const item = request('request-1', 0, 'hello', 1); + + applyScrollbarPromptMarkerClickBehavior(target, item, ChatScrollbarPromptMarkerClickBehavior.Reveal); + assert.deepStrictEqual(calls, ['reveal:request-1']); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatScrollbarPromptMarkerController.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatScrollbarPromptMarkerController.test.ts new file mode 100644 index 00000000000000..0b9566f4262224 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatScrollbarPromptMarkerController.test.ts @@ -0,0 +1,1329 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import * as dom from '../../../../../../base/browser/dom.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ChatScrollbarPromptMarkerClickBehavior } from '../../../common/constants.js'; +import { IChatRequestViewModel, IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; +import { ChatScrollbarPromptMarkerController, IChatScrollbarPromptMarkerHost } from '../../../browser/widget/chatScrollbarPromptMarkerController.js'; + +/** + * A self-contained fake host that mirrors the subset of ChatListWidget methods + * used by ChatScrollbarPromptMarkerController. This allows the controller to be + * tested in isolation without instantiating the full workbench. + */ +class FakeHost extends mock() implements IChatScrollbarPromptMarkerHost { + override readonly renderHeight: number = 0; + override readonly scrollHeight: number = 0; + private readonly _items: (IChatRequestViewModel | IChatResponseViewModel)[] = []; + private readonly _heights = new Map(); + private readonly _tops = new Map(); + private _focus: (IChatRequestViewModel | IChatResponseViewModel)[] = []; + private _layoutInfo: { parent: HTMLElement; insertBefore: HTMLElement } | undefined; + + constructor(opts: { + renderHeight: number; + scrollHeight: number; + items?: (IChatRequestViewModel | IChatResponseViewModel)[]; + heights?: Map; + tops?: Map; + focus?: (IChatRequestViewModel | IChatResponseViewModel)[]; + layoutInfo?: { parent: HTMLElement; insertBefore: HTMLElement }; + }) { + super(); + this.renderHeight = opts.renderHeight; + this.scrollHeight = opts.scrollHeight; + this._items = opts.items ?? []; + this._heights = opts.heights ?? new Map(); + this._tops = opts.tops ?? new Map(); + this._focus = opts.focus ?? []; + this._layoutInfo = opts.layoutInfo; + } + + override getOverviewRulerLayoutInfo() { return this._layoutInfo; } + override getItems() { return this._items; } + override hasElement(element: IChatRequestViewModel | IChatResponseViewModel) { return this._items.includes(element); } + override getElementTop(element: IChatRequestViewModel | IChatResponseViewModel) { return this._tops.get(element.id) ?? 0; } + override getElementHeight(element: IChatRequestViewModel | IChatResponseViewModel) { return this._heights.get(element.id) ?? 0; } + override getFocus() { return this._focus; } + override reveal() { /* no-op */ } + override focusItem() { /* no-op */ } +} + +/** + * Creates a minimal request view model for controller tests. + */ +function makeRequest(id: string): IChatRequestViewModel { + return { + id, + sessionResource: undefined as never, + dataId: id, + username: 'User', + message: undefined as never, + messageText: id, + attempt: 0, + variables: [], + currentRenderedHeight: undefined, + isComplete: true, + isCompleteAddedRequest: false, + agentOrSlashCommandDetected: false, + shouldBeRemovedOnSend: undefined as never, + shouldBeBlocked: undefined as never, + timestamp: 0, + editedFileEvents: undefined, + isSystemInitiated: undefined, + slashCommand: undefined, + } as IChatRequestViewModel; +} + +/** + * Creates a minimal response view model for controller tests. + */ +function makeResponse(requestId: string, parts: unknown[] = []): IChatResponseViewModel { + return { + id: `${requestId}-response`, + sessionResource: undefined as never, + model: { entireResponse: { value: parts } } as never, + dataId: `${requestId}-response`, + session: undefined as never, + username: 'Assistant', + agentOrSlashCommandDetected: false, + response: undefined as never, + usedContext: undefined, + contentReferences: [], + codeCitations: [], + progressMessages: [], + isComplete: true, + isCanceled: false, + isStale: false, + vote: undefined, + requestId, + replyFollowups: undefined, + errorDetails: undefined, + result: undefined, + contentUpdateTimings: undefined, + confirmationAdjustedTimestamp: undefined as never, + usageObs: undefined as never, + completionTokenCountObs: undefined as never, + isCompleteAddedRequest: false, + currentRenderedHeight: undefined, + setVote: () => { }, + setEditApplied: () => { }, + vulnerabilitiesListExpanded: false, + shouldBeRemovedOnSend: undefined as never, + shouldBeBlocked: undefined as never, + } as IChatResponseViewModel; +} + +/** + * Creates a configuration service pre-configured with the given click behavior. + */ +function makeConfigService(behavior: ChatScrollbarPromptMarkerClickBehavior): TestConfigurationService { + return new TestConfigurationService({ + 'chat.scrollbarPromptMarkers.clickBehavior': behavior, + }); +} + +/** + * Creates a layout info object with a parent element and an insertBefore element + * that has a fixed width for scrollbar width calculation. + */ +function makeLayoutInfo(width = 14): { parent: HTMLElement; insertBefore: HTMLElement } { + const parent = document.createElement('div'); + const insertBefore = document.createElement('div'); + parent.appendChild(insertBefore); + // Mock getBoundingClientRect to return the desired width + insertBefore.getBoundingClientRect = () => ({ + width, height: 0, x: 0, y: 0, + left: 0, top: 0, right: width, bottom: 0, + toJSON: () => ({}), + }); + return { parent, insertBefore }; +} + +suite('ChatScrollbarPromptMarkerController', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + // Helper to create a controller with the given host and behavior + function createController(host: IChatScrollbarPromptMarkerHost, behavior: ChatScrollbarPromptMarkerClickBehavior = ChatScrollbarPromptMarkerClickBehavior.Reveal): ChatScrollbarPromptMarkerController { + return disposables.add(new ChatScrollbarPromptMarkerController( + host, + makeConfigService(behavior), + )); + } + + async function flushAnimationFrames(): Promise { + const targetWindow = dom.getWindow(document.body); + await new Promise(resolve => { + targetWindow.requestAnimationFrame(() => targetWindow.requestAnimationFrame(() => resolve())); + }); + } + + suite('layout', () => { + test('places the container inside the overview ruler parent and sizes it to renderHeight x scrollbarWidth', () => { + const layoutInfo = makeLayoutInfo(14); + const host = new FakeHost({ renderHeight: 200, scrollHeight: 400, layoutInfo }); + const controller = createController(host); + + controller.layout(); + + assert.strictEqual(controller['container'].parentElement, layoutInfo.parent); + assert.strictEqual(controller['container'].style.height, '200px'); + assert.strictEqual(controller['container'].style.width, '14px'); + }); + + test('re-attaches parent listeners when the overview ruler parent changes', () => { + const layoutInfo1 = makeLayoutInfo(14); + const layoutInfo2 = makeLayoutInfo(14); + const host = new FakeHost({ renderHeight: 200, scrollHeight: 400, layoutInfo: layoutInfo1 }); + const controller = createController(host); + + controller.layout(); + const listenersAfterFirst = controller['parentPointerDownListener'].value ? 1 : 0; + + // Change to a new parent + (host as unknown as { _layoutInfo: unknown })._layoutInfo = layoutInfo2; + controller.layout(); + const listenersAfterSecond = controller['parentPointerDownListener'].value ? 1 : 0; + + assert.strictEqual(listenersAfterFirst, 1); + assert.strictEqual(listenersAfterSecond, 1); + }); + + test('does not re-attach listeners when the parent stays the same', () => { + const layoutInfo = makeLayoutInfo(14); + const host = new FakeHost({ renderHeight: 200, scrollHeight: 400, layoutInfo }); + const controller = createController(host); + + controller.layout(); + const firstListener = controller['parentPointerDownListener'].value; + + controller.layout(); + const secondListener = controller['parentPointerDownListener'].value; + + // Same listener object — not re-created + assert.strictEqual(firstListener, secondListener); + }); + + test('is a no-op when getOverviewRulerLayoutInfo returns undefined', () => { + const host = new FakeHost({ renderHeight: 200, scrollHeight: 400, layoutInfo: undefined }); + const controller = createController(host); + + controller.layout(); + + assert.strictEqual(controller['container'].parentElement, null); + }); + + test('is a no-op when disabled — does not insert DOM or attach listeners', () => { + const layoutInfo = makeLayoutInfo(14); + const host = new FakeHost({ renderHeight: 200, scrollHeight: 400, layoutInfo }); + const controller = createController(host); + + controller.setEnabled(false); + controller.layout(); + + // Container should not have been inserted into the DOM + assert.strictEqual(controller['container'].parentElement, null); + // Listeners should not have been attached + assert.strictEqual(controller['parentPointerDownListener'].value, undefined); + assert.strictEqual(controller['parentClickListener'].value, undefined); + assert.strictEqual(controller['parentPointerUpListener'].value, undefined); + }); + + test('setEnabled(true) after disabled installs DOM and listeners on next layout', () => { + const layoutInfo = makeLayoutInfo(14); + const host = new FakeHost({ renderHeight: 200, scrollHeight: 400, layoutInfo }); + const controller = createController(host); + + controller.setEnabled(false); + controller.setEnabled(true); + controller.layout(); + + // Container should now be in the DOM + assert.strictEqual(controller['container'].parentElement, layoutInfo.parent); + // Listeners should be attached + assert.notStrictEqual(controller['parentPointerDownListener'].value, undefined); + }); + }); + + suite('renderMarkers', () => { + test('produces one marker element per descriptor with correct data attributes and styles', () => { + const req = makeRequest('r1'); + const res = makeResponse('r1', [{ kind: 'externalEdit' }]); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100], ['r1-response', 100]]); + const tops = new Map([['r1', 0], ['r1-response', 100]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req, res], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + const container = controller['container']; + const markers = container.querySelectorAll('.chat-scrollbar-prompt-marker'); + + assert.strictEqual(markers.length, 2); + + const promptMarker = markers[0] as HTMLElement; + assert.strictEqual(promptMarker.dataset.markerId, 'r1'); + assert.strictEqual(promptMarker.dataset.markerType, 'prompt'); + assert.strictEqual(promptMarker.style.left, 'auto'); + assert.strictEqual(promptMarker.style.right, '0px'); + assert.strictEqual(promptMarker.style.width, '50%'); + assert.strictEqual(promptMarker.style.zIndex, '60'); + + const fileChangeMarker = markers[1] as HTMLElement; + assert.strictEqual(fileChangeMarker.dataset.markerId, 'r1-response'); + assert.strictEqual(fileChangeMarker.dataset.markerType, 'fileChange'); + assert.strictEqual(fileChangeMarker.style.left, '0px'); + assert.strictEqual(fileChangeMarker.style.right, '0px'); + assert.strictEqual(fileChangeMarker.style.width, 'auto'); + assert.strictEqual(fileChangeMarker.style.zIndex, '80'); + }); + + test('left-lane markers get left:0, width:50%', () => { + const req = makeRequest('r1'); + const res = makeResponse('r1', [{ kind: 'questionCarousel', isUsed: false }]); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100], ['r1-response', 100]]); + const tops = new Map([['r1', 0], ['r1-response', 100]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req, res], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + const markers = controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker'); + const askMarker = Array.from(markers).find(m => (m as HTMLElement).dataset.markerType === 'askQuestion') as HTMLElement; + + assert.strictEqual(askMarker.style.left, '0px'); + assert.strictEqual(askMarker.style.right, 'auto'); + assert.strictEqual(askMarker.style.width, '50%'); + }); + + test('active class toggles on the marker whose id matches the focused item', () => { + const req = makeRequest('r1'); + const res = makeResponse('r1', [{ kind: 'externalEdit' }]); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100], ['r1-response', 100]]); + const tops = new Map([['r1', 0], ['r1-response', 100]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req, res], heights, tops, layoutInfo, + focus: [req], + }); + const controller = createController(host); + + controller.layout(); + const markers = controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker'); + const promptMarker = Array.from(markers).find(m => (m as HTMLElement).dataset.markerId === 'r1') as HTMLElement; + + assert.strictEqual(promptMarker.classList.contains('active'), true); + }); + + test('stale markers are removed from the DOM when descriptors shrink', () => { + const req1 = makeRequest('r1'); + const res1 = makeResponse('r1', [{ kind: 'externalEdit' }]); + const req2 = makeRequest('r2'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100], ['r1-response', 100], ['r2', 100]]); + const tops = new Map([['r1', 0], ['r1-response', 100], ['r2', 200]]); + const host = new FakeHost({ + renderHeight: 300, scrollHeight: 300, + items: [req1, res1, req2], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + assert.strictEqual(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker').length, 3); + + // Remove req2 and its response + (host as unknown as { _items: unknown[] })._items = [req1, res1]; + controller.refresh(); + assert.strictEqual(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker').length, 2); + }); + + test('marker DOM nodes are reused across renders when the descriptor id persists', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + const markerBefore = controller['container'].querySelector('.chat-scrollbar-prompt-marker'); + + controller.refresh(); + const markerAfter = controller['container'].querySelector('.chat-scrollbar-prompt-marker'); + + assert.strictEqual(markerBefore, markerAfter); + }); + + test('repeated refresh calls do not accumulate marker DOM nodes', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + for (let i = 0; i < 10; i++) { + controller.refresh(); + } + + assert.strictEqual(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker').length, 1); + }); + + test('minHeight enforcement: a marker whose scaled height is below minHeight is centered around its scaled top', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + // Very small element height relative to scroll height → scaled height < 4 (minHeight) + const heights = new Map([['r1', 1]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 1000, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + const marker = controller['container'].querySelector('.chat-scrollbar-prompt-marker') as HTMLElement; + + // scaledHeight = 1 * (200/1000) = 0.2, which is < 4 (minHeight) + // height = max(4, round(0.2)) = 4, clamped to min(4, 200) = 4 + // top = scaledTop + scaledHeight/2 - height/2 = 0 + 0.1 - 2 = -1.9 → clamped to 0 + assert.strictEqual(marker.style.height, '4px'); + assert.strictEqual(marker.style.top, '0px'); + }); + + test('overlap resolution: when two markers would overlap, the lower one is pushed down', () => { + const req1 = makeRequest('r1'); + const req2 = makeRequest('r2'); + const layoutInfo = makeLayoutInfo(14); + // Both at top=0, height=100, scaled to 100px each in a 200px ruler → they overlap + const heights = new Map([['r1', 100], ['r2', 100]]); + const tops = new Map([['r1', 0], ['r2', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req1, req2], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + const markers = Array.from(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker')) as HTMLElement[]; + const tops2 = markers.map(m => parseInt(m.style.top, 10)); + + // The second marker should be pushed below the first (top >= first.top + first.height + 1) + assert.ok(tops2[1] >= tops2[0] + 4 + 1, `second marker top ${tops2[1]} should be >= ${tops2[0] + 4 + 1}`); + }); + + test('overlap resolution clamps to rulerHeight - height', () => { + const req1 = makeRequest('r1'); + const req2 = makeRequest('r2'); + const layoutInfo = makeLayoutInfo(14); + // Both at top=0, height=100 in a 50px ruler → heavy overlap, must clamp + const heights = new Map([['r1', 100], ['r2', 100]]); + const tops = new Map([['r1', 0], ['r2', 0]]); + const host = new FakeHost({ + renderHeight: 50, scrollHeight: 200, + items: [req1, req2], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + const markers = Array.from(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker')) as HTMLElement[]; + + for (const marker of markers) { + const top = parseInt(marker.style.top, 10); + const height = parseInt(marker.style.height, 10); + assert.ok(top + height <= 50, `marker bottom ${top + height} should not exceed rulerHeight 50`); + } + }); + + test('overlap resolution backward pass: 3 crowded markers do not exceed ruler', () => { + const req1 = makeRequest('r1'); + const req2 = makeRequest('r2'); + const req3 = makeRequest('r3'); + const layoutInfo = makeLayoutInfo(14); + // 3 markers each 10px tall in a 50px ruler, all near the bottom + const heights = new Map([['r1', 10], ['r2', 10], ['r3', 10]]); + const tops = new Map([['r1', 35], ['r2', 38], ['r3', 41]]); + const host = new FakeHost({ + renderHeight: 50, scrollHeight: 50, + items: [req1, req2, req3], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + const markers = Array.from(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker')) as HTMLElement[]; + assert.strictEqual(markers.length, 3); + + // All markers should be within the ruler bounds + for (let i = 0; i < markers.length; i++) { + const top = parseInt(markers[i].style.top, 10); + const height = parseInt(markers[i].style.height, 10); + assert.ok(top >= 0, `marker ${i} top ${top} should be >= 0`); + assert.ok(top + height <= 50, `marker ${i} bottom ${top + height} should not exceed rulerHeight 50`); + } + }); + + test('refreshIfDimensionsChanged skips re-render when dimensions are unchanged', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 400, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + const markerBefore = controller['container'].querySelector('.chat-scrollbar-prompt-marker') as HTMLElement; + const topBefore = markerBefore.style.top; + + // Same dimensions — should be a no-op + controller.refreshIfDimensionsChanged(); + const markerAfter = controller['container'].querySelector('.chat-scrollbar-prompt-marker') as HTMLElement; + assert.strictEqual(markerAfter.style.top, topBefore); + }); + + test('refreshIfDimensionsChanged re-renders when scrollHeight changes', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 100]]); + let scrollHeight = 400; + const host = new FakeHost({ + renderHeight: 200, scrollHeight, + items: [req], heights, tops, layoutInfo, + }); + // Override scrollHeight getter to be mutable + Object.defineProperty(host, 'scrollHeight', { get: () => scrollHeight }); + const controller = createController(host); + + controller.layout(); + const markerBefore = controller['container'].querySelector('.chat-scrollbar-prompt-marker') as HTMLElement; + const topBefore = markerBefore.style.top; + + // Change scrollHeight — should trigger re-render with different positions + scrollHeight = 800; + controller.refreshIfDimensionsChanged(); + const markerAfter = controller['container'].querySelector('.chat-scrollbar-prompt-marker') as HTMLElement; + assert.notStrictEqual(markerAfter.style.top, topBefore); + }); + }); + + suite('setVisible', () => { + test('setVisible(false) hides the container; setVisible(true) shows it when renderHeight > 0', () => { + const layoutInfo = makeLayoutInfo(14); + const host = new FakeHost({ renderHeight: 200, scrollHeight: 400, layoutInfo }); + const controller = createController(host); + + controller.layout(); + assert.notStrictEqual(controller['container'].style.display, 'none'); + + controller.setVisible(false); + assert.strictEqual(controller['container'].style.display, 'none'); + + controller.setVisible(true); + assert.notStrictEqual(controller['container'].style.display, 'none'); + }); + }); + + suite('getTargetAtPoint', () => { + test('returns the correct target for a click inside a marker Y range, even in the opposite lane', () => { + const req = makeRequest('r1'); + const res = makeResponse('r1', [{ kind: 'externalEdit' }]); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100], ['r1-response', 100]]); + const tops = new Map([['r1', 0], ['r1-response', 100]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req, res], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + const container = controller['container']; + + // Mock container getBoundingClientRect for hit-testing + container.getBoundingClientRect = () => ({ + width: 14, height: 200, x: 0, y: 0, + left: 0, top: 0, right: 14, bottom: 200, + toJSON: () => ({}), + }); + + // Marker is at top=0, height=4 (set by renderMarkers via layout) + // No need to mock marker.getBoundingClientRect — hit-testing uses cached style values + + // Click at x=0 (left side, opposite lane), y=2 (within marker Y range) + const target = controller['getTargetAtPoint'](0, 2); + assert.strictEqual(target, req); + }); + + test('returns undefined for a click outside the container bounds', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + const container = controller['container']; + container.getBoundingClientRect = () => ({ + width: 14, height: 200, x: 0, y: 0, + left: 0, top: 0, right: 14, bottom: 200, + toJSON: () => ({}), + }); + + // Click far outside + const target = controller['getTargetAtPoint'](1000, 1000); + assert.strictEqual(target, undefined); + }); + + test('returns undefined when visible is false', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + controller.setVisible(false); + + const container = controller['container']; + container.getBoundingClientRect = () => ({ + width: 14, height: 200, x: 0, y: 0, + left: 0, top: 0, right: 14, bottom: 200, + toJSON: () => ({}), + }); + + const target = controller['getTargetAtPoint'](0, 2); + assert.strictEqual(target, undefined); + }); + + test('overlapping markers at the same Y: right-lane wins over left-lane wins over full-lane', () => { + const req = makeRequest('r1'); // prompt → right lane + const res = makeResponse('r1', [{ kind: 'questionCarousel', isUsed: false }]); // askQuestion → left lane + const layoutInfo = makeLayoutInfo(14); + // Both at the same position so they overlap + const heights = new Map([['r1', 100], ['r1-response', 100]]); + const tops = new Map([['r1', 0], ['r1-response', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req, res], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + const container = controller['container']; + container.getBoundingClientRect = () => ({ + width: 14, height: 200, x: 0, y: 0, + left: 0, top: 0, right: 14, bottom: 200, + toJSON: () => ({}), + }); + + // Both markers overlap at Y=0..4 — set inline styles for cached hit-testing + const markers = container.querySelectorAll('.chat-scrollbar-prompt-marker'); + for (const marker of markers) { + (marker as HTMLElement).style.top = '0px'; + (marker as HTMLElement).style.height = '4px'; + } + + // Both markers overlap at Y=2; right-lane (prompt) should win + const target = controller['getTargetAtPoint'](7, 2); + assert.strictEqual(target, req); + }); + }); + + suite('edge cases', () => { + test('scrollHeight <= 0 clears all markers and target maps', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 0, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + + assert.strictEqual(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker').length, 0); + assert.strictEqual(controller['markerById'].size, 0); + assert.strictEqual(controller['targetById'].size, 0); + }); + + test('renderHeight <= 0 clears all markers and hides the container', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 0, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + + assert.strictEqual(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker').length, 0); + assert.strictEqual(controller['container'].style.display, 'none'); + }); + + test('getOverviewRulerLayoutInfo returns undefined → renderMarkers is a no-op', () => { + const req = makeRequest('r1'); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], layoutInfo: undefined, + }); + const controller = createController(host); + + controller.refresh(); + + assert.strictEqual(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker').length, 0); + }); + + test('no descriptors (empty items) → no marker elements', () => { + const layoutInfo = makeLayoutInfo(14); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [], layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + + assert.strictEqual(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker').length, 0); + }); + }); + + suite('revealItem', () => { + test('setVisible(false) cancels pending focus retries', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const calls: string[] = []; + let hasElementAttempts = 0; + const host = new class extends FakeHost { + constructor() { + super({ renderHeight: 200, scrollHeight: 200, items: [req], heights, tops, layoutInfo }); + } + override reveal() { calls.push('reveal'); } + override focusItem() { calls.push('focusItem'); } + override hasElement() { + hasElementAttempts++; + return hasElementAttempts > 1; + } + }(); + const controller = createController(host, ChatScrollbarPromptMarkerClickBehavior.RevealAndFocus); + + controller['revealItem'](req); + controller.setVisible(false); + + await flushAnimationFrames(); + + assert.deepStrictEqual(calls, ['reveal']); + })); + + test('setEnabled(false) cancels pending focus retries', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const calls: string[] = []; + let hasElementAttempts = 0; + const host = new class extends FakeHost { + constructor() { + super({ renderHeight: 200, scrollHeight: 200, items: [req], heights, tops, layoutInfo }); + } + override reveal() { calls.push('reveal'); } + override focusItem() { calls.push('focusItem'); } + override hasElement() { + hasElementAttempts++; + return hasElementAttempts > 1; + } + }(); + const controller = createController(host, ChatScrollbarPromptMarkerClickBehavior.RevealAndFocus); + + controller['revealItem'](req); + controller.setEnabled(false); + + await flushAnimationFrames(); + + assert.deepStrictEqual(calls, ['reveal']); + })); + + test('dispose cancels pending focus retries', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const calls: string[] = []; + let hasElementAttempts = 0; + const host = new class extends FakeHost { + constructor() { + super({ renderHeight: 200, scrollHeight: 200, items: [req], heights, tops, layoutInfo }); + } + override reveal() { calls.push('reveal'); } + override focusItem() { calls.push('focusItem'); } + override hasElement() { + hasElementAttempts++; + return hasElementAttempts > 1; + } + }(); + const controller = createController(host, ChatScrollbarPromptMarkerClickBehavior.RevealAndFocus); + + controller['revealItem'](req); + controller.dispose(); + + await flushAnimationFrames(); + + assert.deepStrictEqual(calls, ['reveal']); + })); + + test('with Reveal calls reveal only and never focusItem', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const calls: string[] = []; + const host = new class extends FakeHost { + constructor() { + super({ renderHeight: 200, scrollHeight: 200, items: [req], heights, tops, layoutInfo }); + } + override reveal() { calls.push('reveal'); } + override focusItem() { calls.push('focusItem'); } + }(); + const controller = createController(host, ChatScrollbarPromptMarkerClickBehavior.Reveal); + + controller['revealItem'](req); + + assert.deepStrictEqual(calls, ['reveal']); + }); + + test('with RevealAndFocus calls reveal immediately and retries focusItem across animation frames', async () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const calls: string[] = []; + let hasElementCountdown = 1; + const host = new class extends FakeHost { + constructor() { + super({ renderHeight: 200, scrollHeight: 200, items: [req], heights, tops, layoutInfo }); + } + override reveal() { calls.push('reveal'); } + override focusItem() { calls.push('focusItem'); } + override hasElement() { + if (hasElementCountdown > 0) { + hasElementCountdown--; + return false; + } + return true; + } + }(); + const controller = createController(host, ChatScrollbarPromptMarkerClickBehavior.RevealAndFocus); + + controller['revealItem'](req); + + // reveal is called immediately; focusItem is deferred to animation frames + assert.deepStrictEqual(calls, ['reveal']); + + // Flush scheduled animation frames deterministically + await flushAnimationFrames(); + + // focusItem should have been called once after hasElement returned true + assert.ok(calls.includes('focusItem'), `expected focusItem to be called, got: ${JSON.stringify(calls)}`); + assert.strictEqual(calls.filter(c => c === 'focusItem').length, 1); + }); + }); + + suite('pointer/click event handling', () => { + test('pointerdown on a marker sets markerActivated, calls preventDefault/stopPropagation, and reveals', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const calls: string[] = []; + const host = new class extends FakeHost { + constructor() { + super({ renderHeight: 200, scrollHeight: 200, items: [req], heights, tops, layoutInfo }); + } + override reveal() { calls.push('reveal'); } + }(); + const controller = createController(host); + + controller.layout(); + const container = controller['container']; + container.getBoundingClientRect = () => ({ + width: 14, height: 200, x: 0, y: 0, + left: 0, top: 0, right: 14, bottom: 200, + toJSON: () => ({}), + }); + + // Marker is at top=0, height=4 (set by renderMarkers via layout) + // No need to mock marker.getBoundingClientRect — hit-testing uses cached style values + + let prevented = false; + let stopped = false; + const event = { + clientX: 7, clientY: 2, + preventDefault: () => { prevented = true; }, + stopPropagation: () => { stopped = true; }, + } as unknown as PointerEvent; + + controller['onOverviewRulerPointerDown'](event); + + assert.strictEqual(controller['markerActivated'], true); + assert.strictEqual(prevented, true); + assert.strictEqual(stopped, true); + assert.deepStrictEqual(calls, ['reveal']); + }); + + test('pointerdown outside any marker does not activate or reveal', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const calls: string[] = []; + const host = new class extends FakeHost { + constructor() { + super({ renderHeight: 200, scrollHeight: 200, items: [req], heights, tops, layoutInfo }); + } + override reveal() { calls.push('reveal'); } + }(); + const controller = createController(host); + + controller.layout(); + const container = controller['container']; + container.getBoundingClientRect = () => ({ + width: 14, height: 200, x: 0, y: 0, + left: 0, top: 0, right: 14, bottom: 200, + toJSON: () => ({}), + }); + + let prevented = false; + const event = { + clientX: 7, clientY: 199, // outside marker Y range + preventDefault: () => { prevented = true; }, + stopPropagation: () => { }, + } as unknown as PointerEvent; + + controller['onOverviewRulerPointerDown'](event); + + assert.strictEqual(controller['markerActivated'], false); + assert.strictEqual(prevented, false); + assert.deepStrictEqual(calls, []); + }); + + test('pointerup and click are suppressed when markerActivated is true', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + controller['markerActivated'] = true; + + // pointerup happens before click in the event sequence + let pointerupPrevented = false; + let pointerupStopped = false; + const pointerupEvent = { + preventDefault: () => { pointerupPrevented = true; }, + stopPropagation: () => { pointerupStopped = true; }, + } as unknown as PointerEvent; + controller['onOverviewRulerPointerUp'](pointerupEvent); + + assert.strictEqual(controller['markerActivated'], false); + assert.strictEqual(controller['suppressNextClick'], true); + assert.strictEqual(pointerupPrevented, true); + assert.strictEqual(pointerupStopped, true); + + let clickPrevented = false; + let clickStopped = false; + const clickEvent = { + preventDefault: () => { clickPrevented = true; }, + stopPropagation: () => { clickStopped = true; }, + } as unknown as MouseEvent; + controller['onOverviewRulerClick'](clickEvent); + + assert.strictEqual(clickPrevented, true); + assert.strictEqual(clickStopped, true); + assert.strictEqual(controller['suppressNextClick'], false); + }); + + test('click suppression clears on the next frame when no click follows pointerup', async () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + controller['markerActivated'] = true; + + controller['onOverviewRulerPointerUp']({ + preventDefault: () => { }, + stopPropagation: () => { }, + } as unknown as PointerEvent); + + assert.strictEqual(controller['suppressNextClick'], true); + + await flushAnimationFrames(); + + assert.strictEqual(controller['suppressNextClick'], false); + }); + + test('click and pointerup are not suppressed when markerActivated is false', () => { + const controller = createController(new FakeHost({ renderHeight: 200, scrollHeight: 200, layoutInfo: makeLayoutInfo(14) })); + + let prevented = false; + const clickEvent = { + preventDefault: () => { prevented = true; }, + stopPropagation: () => { }, + } as unknown as MouseEvent; + const pointerUpEvent = { + preventDefault: () => { prevented = true; }, + stopPropagation: () => { }, + } as unknown as PointerEvent; + + controller['onOverviewRulerClick'](clickEvent); + controller['onOverviewRulerPointerUp'](pointerUpEvent); + + assert.strictEqual(prevented, false); + }); + }); + + test('non-primary mouse button does not activate or reveal', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const calls: string[] = []; + const host = new class extends FakeHost { + constructor() { + super({ renderHeight: 200, scrollHeight: 200, items: [req], heights, tops, layoutInfo }); + } + override reveal() { calls.push('reveal'); } + }(); + const controller = createController(host); + + controller.layout(); + const container = controller['container']; + container.getBoundingClientRect = () => ({ + width: 14, height: 200, x: 0, y: 0, + left: 0, top: 0, right: 14, bottom: 200, + toJSON: () => ({}), + }); + + let prevented = false; + const event = { + clientX: 7, clientY: 2, + pointerType: 'mouse', + button: 2, + preventDefault: () => { prevented = true; }, + stopPropagation: () => { }, + } as unknown as PointerEvent; + + controller['onOverviewRulerPointerDown'](event); + + assert.strictEqual(controller['markerActivated'], false); + assert.strictEqual(prevented, false); + assert.deepStrictEqual(calls, []); + }); + + test('pointercancel resets gesture state so a later pointerup does not arm suppression', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + controller['markerActivated'] = true; + + controller['onOverviewRulerPointerCancel'](); + + assert.strictEqual(controller['markerActivated'], false); + assert.strictEqual(controller['suppressNextClick'], false); + + let pointerupPrevented = false; + controller['onOverviewRulerPointerUp']({ + preventDefault: () => { pointerupPrevented = true; }, + stopPropagation: () => { }, + } as unknown as PointerEvent); + + assert.strictEqual(pointerupPrevented, false); + assert.strictEqual(controller['suppressNextClick'], false); + }); + + test('setVisible(false) mid-gesture resets markerActivated', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + controller['markerActivated'] = true; + + controller.setVisible(false); + + assert.strictEqual(controller['markerActivated'], false); + assert.strictEqual(controller['suppressNextClick'], false); + }); + + test('setEnabled(false) mid-gesture resets markerActivated', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + controller['markerActivated'] = true; + + controller.setEnabled(false); + + assert.strictEqual(controller['markerActivated'], false); + assert.strictEqual(controller['suppressNextClick'], false); + }); + + suite('lifecycle and memory leaks', () => { + test('after dispose: container is removed from DOM, maps are empty, and no parent listeners remain', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const calls: string[] = []; + const host = new class extends FakeHost { + constructor() { + super({ renderHeight: 200, scrollHeight: 200, items: [req], heights, tops, layoutInfo }); + } + override reveal() { calls.push('reveal'); } + override focusItem() { calls.push('focusItem'); } + }(); + const controller = disposables.add(new ChatScrollbarPromptMarkerController( + host, + makeConfigService(ChatScrollbarPromptMarkerClickBehavior.Reveal), + )); + + controller.layout(); + assert.ok(controller['container'].parentElement); + + controller.dispose(); + + assert.strictEqual(controller['container'].parentElement, null); + assert.strictEqual(controller['parentPointerDownListener'].value, undefined); + assert.strictEqual(controller['parentClickListener'].value, undefined); + assert.strictEqual(controller['parentPointerUpListener'].value, undefined); + + // Dispatching events on the former parent should not call any host methods + calls.length = 0; + layoutInfo.parent.dispatchEvent(new PointerEvent('pointerdown')); + layoutInfo.parent.dispatchEvent(new MouseEvent('click')); + layoutInfo.parent.dispatchEvent(new PointerEvent('pointerup')); + assert.deepStrictEqual(calls, []); + }); + + test('repeated layout calls with the same parent do not register additional listeners', () => { + const layoutInfo = makeLayoutInfo(14); + const host = new FakeHost({ renderHeight: 200, scrollHeight: 200, layoutInfo }); + const controller = createController(host); + + controller.layout(); + const firstPointerDown = controller['parentPointerDownListener'].value; + + controller.layout(); + controller.layout(); + const finalPointerDown = controller['parentPointerDownListener'].value; + + assert.strictEqual(firstPointerDown, finalPointerDown); + }); + + test('renderMarkers called many times with the same descriptors does not leak stale nodes', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + for (let i = 0; i < 20; i++) { + controller.refresh(); + } + + assert.strictEqual(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker').length, 1); + assert.strictEqual(controller['markerById'].size, 1); + }); + }); + + suite('setEnabled', () => { + test('setEnabled(false) hides the container and clears all marker nodes', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + assert.strictEqual(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker').length, 1); + assert.strictEqual(controller['container'].style.display, ''); + + controller.setEnabled(false); + + assert.strictEqual(controller['container'].style.display, 'none'); + assert.strictEqual(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker').length, 0); + assert.strictEqual(controller['markerById'].size, 0); + }); + + test('setEnabled(true) after disable re-renders markers and shows the container', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + controller.setEnabled(false); + assert.strictEqual(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker').length, 0); + + controller.setEnabled(true); + + assert.strictEqual(controller['container'].style.display, ''); + assert.strictEqual(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker').length, 1); + assert.strictEqual(controller['markerById'].size, 1); + }); + + test('setEnabled is a no-op when the value does not change', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + const markerCountBefore = controller['markerById'].size; + + controller.setEnabled(true); + + assert.strictEqual(controller['markerById'].size, markerCountBefore); + }); + + test('disabled controller does not render markers on layout or refresh', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + controller.setEnabled(false); + + controller.layout(); + controller.refresh(); + + assert.strictEqual(controller['container'].querySelectorAll('.chat-scrollbar-prompt-marker').length, 0); + assert.strictEqual(controller['markerById'].size, 0); + }); + + test('setEnabled(false) detaches the overlay container and disposes parent listeners', () => { + const req = makeRequest('r1'); + const layoutInfo = makeLayoutInfo(14); + const heights = new Map([['r1', 100]]); + const tops = new Map([['r1', 0]]); + const host = new FakeHost({ + renderHeight: 200, scrollHeight: 200, + items: [req], heights, tops, layoutInfo, + }); + const controller = createController(host); + + controller.layout(); + // Container is attached and parent listeners are installed + assert.strictEqual(controller['container'].parentElement, layoutInfo.parent); + assert.ok(controller['pointerDownListenerParent'] === layoutInfo.parent); + assert.ok(controller['parentPointerDownListener'].value); + assert.ok(controller['parentClickListener'].value); + + controller.setEnabled(false); + + // Container must be fully detached from the DOM + assert.strictEqual(controller['container'].parentElement, null); + // Parent listeners must be disposed so the feature is a true no-op + assert.ok(!controller['parentPointerDownListener'].value); + assert.ok(!controller['parentClickListener'].value); + assert.ok(!controller['parentPointerUpListener'].value); + assert.ok(!controller['parentPointerCancelListener'].value); + assert.strictEqual(controller['pointerDownListenerParent'], undefined); + }); + }); +});