From cdd4df70cf6f65086dcfda950f7644d4111f21d4 Mon Sep 17 00:00:00 2001 From: Tajudeen Date: Thu, 23 Jul 2026 14:08:13 +0100 Subject: [PATCH] refactor(ui): extract ChatMessageList and context usage from SidebarChat Thin SidebarChat by moving the message scroll area, staging chips, and context budget hook into composer modules, and migrate Void onboarding CTAs to design-system buttons. Co-authored-by: Cursor --- .../react/src/onboarding/VoidOnboarding.tsx | 5 +- .../react/src/sidebar-tsx/SidebarChat.tsx | 274 +++--------------- .../sidebar-tsx/composer/ChatMessageList.tsx | 163 +++++++++++ .../composer/ScrollToBottomContainer.tsx | 2 +- .../composer/StagingContextChips.tsx | 55 ++++ .../sidebar-tsx/composer/useContextUsage.ts | 54 ++++ .../test/common/designSystem.test.ts | 10 + 7 files changed, 319 insertions(+), 244 deletions(-) create mode 100644 src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/ChatMessageList.tsx create mode 100644 src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/StagingContextChips.tsx create mode 100644 src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/useContextUsage.ts diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/onboarding/VoidOnboarding.tsx b/src/vs/workbench/contrib/cortexide/browser/react/src/onboarding/VoidOnboarding.tsx index ab8ce1b1f49..9751d52dd84 100644 --- a/src/vs/workbench/contrib/cortexide/browser/react/src/onboarding/VoidOnboarding.tsx +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/onboarding/VoidOnboarding.tsx @@ -284,7 +284,8 @@ const AddProvidersPage = ({ pageIndex, setPageIndex }: { pageIndex: number, setP
{currentTab === 'Local' && !showLocalWizard && ( - - ) - })} -
- )} + { chatThreadsService.popStagingSelections(1) }} + /> diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/ChatMessageList.tsx b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/ChatMessageList.tsx new file mode 100644 index 00000000000..97878eb7498 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/ChatMessageList.tsx @@ -0,0 +1,163 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import React, { RefObject, useMemo } from 'react'; +import { useAccessor, useChatThreadsStreamState } from '../../util/services.js'; +import { ChatMessage } from '../../../../common/chatThreadServiceTypes.js'; +import { IsRunningType } from '../../../chatThreadService.js'; +import { ScrollToBottomContainer } from './ScrollToBottomContainer.js'; +import { ChatBubble } from '../chat/ChatBubble.js'; +import { EditToolSoFar } from '../tools/ToolRenderers.js'; +import { ProseWrapper } from '../chat/proseWrappers.js'; +import { IconLoading } from '../shared/icons.js'; +import { ErrorDisplay } from '../ErrorDisplay.js'; +import { WarningBox } from '../../settings/WarningBox.js'; +import { CORTEXIDE_OPEN_SETTINGS_ACTION_ID } from '../../../cortexideSettingsPane.js'; + +type StreamError = { message: string; fullError?: string } | undefined; + +type ChatMessageListProps = { + threadId: string; + previousMessages: ChatMessage[]; + currCheckpointIdx: number | undefined; + isRunning: IsRunningType | undefined; + displayContentSoFar: string | undefined; + reasoningSoFar: string | undefined; + toolCallSoFar: { name: string; isDone?: boolean; rawParams?: string } | undefined; + latestError: StreamError; + scrollContainerRef: RefObject; + scrollToBottomCallback: (() => void) | null; +}; + +export const ChatMessageList = ({ + threadId, + previousMessages, + currCheckpointIdx, + isRunning, + displayContentSoFar, + reasoningSoFar, + toolCallSoFar, + latestError, + scrollContainerRef, + scrollToBottomCallback, +}: ChatMessageListProps) => { + const accessor = useAccessor(); + const commandService = accessor.get('ICommandService'); + const chatThreadsService = accessor.get('IChatThreadService'); + const currThreadStreamState = useChatThreadsStreamState(threadId); + + const previousMessagesHTML = useMemo(() => { + return previousMessages.map((message, i) => { + const messageKey = (message as { id?: string }).id || `msg-${i}`; + return ; + }); + }, [previousMessages, threadId, currCheckpointIdx, isRunning, scrollToBottomCallback]); + + const streamingChatIdx = previousMessagesHTML.length; + const streamingChatMessage = useMemo(() => ({ + role: 'assistant' as const, + displayContent: displayContentSoFar ?? '', + reasoning: reasoningSoFar ?? '', + anthropicReasoning: null, + }), [displayContentSoFar, reasoningSoFar]); + + const isActivelyStreaming = isRunning === 'LLM' || isRunning === 'tool' || isRunning === 'preparing'; + const toolIsGenerating = toolCallSoFar && !toolCallSoFar.isDone; + + const currStreamingMessageHTML = isActivelyStreaming && (reasoningSoFar || displayContentSoFar) + ? + : null; + + const generatingTool = toolIsGenerating && (toolCallSoFar.name === 'edit_file' || toolCallSoFar.name === 'rewrite_file') + ? + : null; + + return ( + + {previousMessagesHTML} + {currStreamingMessageHTML} + {generatingTool} + + {(isRunning === 'LLM' || isRunning === 'preparing') && !displayContentSoFar && !reasoningSoFar ? ( + +
+
+ {isRunning === 'preparing' && currThreadStreamState?.llmInfo?.displayContentSoFar ? ( + <> + {currThreadStreamState.llmInfo.displayContentSoFar} + + + ) : isRunning === 'preparing' ? ( + <> + Preparing request + + + ) : ( + <> + Generating response + + + )} +
+ Press Escape to cancel +
+
+ ) : null} + + {(isRunning === 'LLM' || isRunning === 'preparing') && (displayContentSoFar || reasoningSoFar) ? ( +

Press Escape to cancel

+ ) : null} + + {latestError === undefined ? null : ( +
+ { chatThreadsService.dismissStreamError(threadId); }} + showDismiss={true} + /> +

+ You can try again or open settings to change the model. +

+ { commandService.executeCommand(CORTEXIDE_OPEN_SETTINGS_ACTION_ID); }} text='Open settings' /> +
+ )} +
+ ); +}; diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/ScrollToBottomContainer.tsx b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/ScrollToBottomContainer.tsx index a5a63334037..210d3755364 100644 --- a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/ScrollToBottomContainer.tsx +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/ScrollToBottomContainer.tsx @@ -5,7 +5,7 @@ import React, { useEffect, useState } from 'react'; -const scrollToBottom = (divRef: { current: HTMLElement | null }) => { +export const scrollToBottom = (divRef: { current: HTMLElement | null }) => { if (divRef.current) { divRef.current.scrollTop = divRef.current.scrollHeight; } diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/StagingContextChips.tsx b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/StagingContextChips.tsx new file mode 100644 index 00000000000..63b96b042e3 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/StagingContextChips.tsx @@ -0,0 +1,55 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import React from 'react'; +import { StagingSelectionItem } from '../../../../common/chatThreadServiceTypes.js'; + +type StagingContextChipsProps = { + selections: StagingSelectionItem[]; + onRemoveLast: () => void; +}; + +export const StagingContextChips = ({ selections, onRemoveLast }: StagingContextChipsProps) => { + if (selections.length === 0) { + return null; + } + + return ( +
+ {selections.map((sel, idx) => { + const name = sel.type === 'Folder' + ? (sel.uri?.path?.split('/').filter(Boolean).pop() || 'folder') + : (sel.uri?.path?.split('/').pop() || 'file'); + const fullPath = sel.uri?.fsPath || sel.uri?.path || name; + const rangeLabel = (sel as { range?: { startLineNumber: number; endLineNumber: number } }).range + ? ` • ${(sel as { range: { startLineNumber: number; endLineNumber: number } }).range.startLineNumber}-${(sel as { range: { startLineNumber: number; endLineNumber: number } }).range.endLineNumber}` + : ''; + const tooltipText = (sel as { range?: { startLineNumber: number; endLineNumber: number } }).range + ? `${fullPath} (lines ${(sel as { range: { startLineNumber: number; endLineNumber: number } }).range.startLineNumber}-${(sel as { range: { startLineNumber: number; endLineNumber: number } }).range.endLineNumber})` + : fullPath; + return ( + + {sel.type === 'Folder' ? 'Folder' : 'File'} + {name} + {rangeLabel && {rangeLabel}} + + + ); + })} +
+ ); +}; diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/useContextUsage.ts b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/useContextUsage.ts new file mode 100644 index 00000000000..bf909eb5e46 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/useContextUsage.ts @@ -0,0 +1,54 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAccessor, useSettingsState } from '../../util/services.js'; +import { ChatMessage } from '../../../../common/chatThreadServiceTypes.js'; +import { getIsReasoningEnabledState, getReservedOutputTokenSpace, getModelCapabilities } from '../../../../common/modelCapabilities.js'; +import { isValidProviderModelSelection } from '../../../../../../../workbench/contrib/cortexide/common/cortexideSettingsTypes.js'; + +export const useContextUsage = (previousMessages: ChatMessage[], draftText: string) => { + const accessor = useAccessor(); + const settingsState = useSettingsState(); + const [ctxWarned, setCtxWarned] = useState(false); + const estimateTokens = useCallback((s: string) => Math.ceil((s || '').length / 4), []); + const modelSel = settingsState.modelSelectionOfFeature['Chat']; + + const { contextBudget, messagesTokens } = useMemo(() => { + let budget = 0; + let tokens = 0; + if (modelSel && isValidProviderModelSelection(modelSel)) { + const { providerName, modelName } = modelSel; + const caps = getModelCapabilities(providerName, modelName, settingsState.overridesOfModel); + const contextWindow = caps.contextWindow; + const msOpts = settingsState.optionsOfModelSelection['Chat'][providerName]?.[modelName]; + const isReasoningEnabled = getIsReasoningEnabledState('Chat', providerName, modelName, msOpts, settingsState.overridesOfModel); + const rot = getReservedOutputTokenSpace(providerName, modelName, { isReasoningEnabled, overridesOfModel: settingsState.overridesOfModel }) || 0; + budget = Math.max(256, Math.floor(contextWindow * 0.8) - rot); + tokens = previousMessages.reduce((acc, m) => { + if (m.role === 'user') return acc + estimateTokens(m.content || ''); + if (m.role === 'assistant') return acc + estimateTokens((m.displayContent as string) || (m.content || '') || ''); + return acc; + }, 0); + } + return { contextBudget: budget, messagesTokens: tokens }; + }, [modelSel, previousMessages, settingsState.overridesOfModel, settingsState.optionsOfModelSelection, estimateTokens]); + + const draftTokens = estimateTokens(draftText); + const contextTotal = messagesTokens + draftTokens; + const contextPct = contextBudget > 0 ? contextTotal / contextBudget : 0; + + useEffect(() => { + if (contextPct > 0.8 && contextPct < 1 && !ctxWarned) { + try { + accessor.get('INotificationService').info(`Context nearing limit: ~${contextTotal} / ${contextBudget} tokens. Older messages may be summarized.`); + } catch { /* noop */ } + setCtxWarned(true); + } + if (contextPct < 0.6 && ctxWarned) setCtxWarned(false); + }, [contextPct, ctxWarned, contextTotal, contextBudget, accessor]); + + return { modelSel, contextTotal, contextBudget, contextPct }; +}; diff --git a/src/vs/workbench/contrib/cortexide/test/common/designSystem.test.ts b/src/vs/workbench/contrib/cortexide/test/common/designSystem.test.ts index 766027126bb..b0ee7f375af 100644 --- a/src/vs/workbench/contrib/cortexide/test/common/designSystem.test.ts +++ b/src/vs/workbench/contrib/cortexide/test/common/designSystem.test.ts @@ -74,8 +74,18 @@ suite('designSystem (Phase 1 — onboarding adoption)', () => { }); }); +const voidOnboardingPath = join(dirname(fileURLToPath(import.meta.url)), '../../browser/react/src/onboarding/VoidOnboarding.tsx'); const settingsPath = join(dirname(fileURLToPath(import.meta.url)), '../../browser/react/src/settings/Settings.tsx'); +suite('designSystem (Phase 1 — void onboarding adoption)', () => { + + test('Void onboarding welcome CTAs use design-system buttons', () => { + const src = readFileSync(voidOnboardingPath, 'utf8'); + assert.ok(src.includes('btn btn-primary'), 'expected btn-primary in void onboarding'); + assert.ok(src.includes('btn btn-secondary'), 'expected btn-secondary in void onboarding'); + }); +}); + suite('designSystem (Phase 1 — settings adoption)', () => { test('Settings pane uses design-system button classes', () => {