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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,8 @@ const AddProvidersPage = ({ pageIndex, setPageIndex }: { pageIndex: number, setP
<div className="space-y-6 overflow-y-auto pr-1 flex-1">
{currentTab === 'Local' && !showLocalWizard && (
<button
className="w-full flex items-center justify-between px-5 py-4 rounded-xl border-2 border-[var(--cortex-brand)]/40 bg-[var(--cortex-brand)]/10 hover:bg-[var(--cortex-brand)]/20 transition-colors text-left"
type="button"
className="btn btn-primary w-full flex items-center justify-between px-5 py-4 text-left"
onClick={() => setShowLocalWizard(true)}
>
<div>
Expand All @@ -298,7 +299,7 @@ const AddProvidersPage = ({ pageIndex, setPageIndex }: { pageIndex: number, setP
{currentTab === 'Local' && !showLocalWizard && (
<button
type="button"
className="w-full flex items-center justify-between px-5 py-4 rounded-xl border border-void-border-3 bg-void-bg-3/60 hover:bg-void-bg-2/80 transition-colors text-left"
className="btn btn-secondary w-full flex items-center justify-between px-5 py-4 text-left"
onClick={() => { void applyLlamaServerPreset(settingsService); }}
>
<div>
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>;
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 <ChatBubble
key={messageKey}
currCheckpointIdx={currCheckpointIdx}
chatMessage={message}
messageIdx={i}
isCommitted={true}
chatIsRunning={isRunning}
threadId={threadId}
_scrollToBottom={scrollToBottomCallback}
/>;
});
}, [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)
? <ChatBubble
key={'curr-streaming-msg'}
currCheckpointIdx={currCheckpointIdx}
chatMessage={streamingChatMessage}
messageIdx={streamingChatIdx}
isCommitted={false}
chatIsRunning={isRunning}
threadId={threadId}
_scrollToBottom={null}
/>
: null;

const generatingTool = toolIsGenerating && (toolCallSoFar.name === 'edit_file' || toolCallSoFar.name === 'rewrite_file')
? <EditToolSoFar key={'curr-streaming-tool'} toolCallSoFar={toolCallSoFar} />
: null;

return (
<ScrollToBottomContainer
key={'messages' + threadId}
scrollContainerRef={scrollContainerRef}
className={`
flex flex-col
px-3 py-3 space-y-3
w-full h-full
overflow-x-hidden
overflow-y-auto
${previousMessagesHTML.length === 0 && !displayContentSoFar ? 'hidden' : ''}
`}
>
{previousMessagesHTML}
{currStreamingMessageHTML}
{generatingTool}

{(isRunning === 'LLM' || isRunning === 'preparing') && !displayContentSoFar && !reasoningSoFar ? (
<ProseWrapper>
<div
className="flex flex-col gap-1"
role="status"
aria-live="polite"
aria-atomic="true"
>
<div className="flex items-center gap-2 text-sm opacity-70 loading-state-transition">
{isRunning === 'preparing' && currThreadStreamState?.llmInfo?.displayContentSoFar ? (
<>
<span className="text-void-fg-2">{currThreadStreamState.llmInfo.displayContentSoFar}</span>
<IconLoading state="thinking" inline />
</>
) : isRunning === 'preparing' ? (
<>
<span className="text-void-fg-2">Preparing request</span>
<IconLoading state="thinking" inline />
</>
) : (
<>
<span className="text-void-fg-2">Generating response</span>
<IconLoading state="typing" inline />
</>
)}
</div>
<span className="text-xs text-void-fg-3 opacity-60">Press Escape to cancel</span>
</div>
</ProseWrapper>
) : null}

{(isRunning === 'LLM' || isRunning === 'preparing') && (displayContentSoFar || reasoningSoFar) ? (
<p className="text-xs text-void-fg-3 opacity-60 mt-1" role="status">Press Escape to cancel</p>
) : null}

{latestError === undefined ? null : (
<div className='px-2 my-1 message-enter space-y-2'>
<ErrorDisplay
message={latestError.message}
fullError={latestError.fullError}
onDismiss={() => { chatThreadsService.dismissStreamError(threadId); }}
showDismiss={true}
/>
<p className="text-sm text-void-fg-3 px-1">
You can try again or open settings to change the model.
</p>
<WarningBox className='text-sm my-1 mx-3' onClick={() => { commandService.executeCommand(CORTEXIDE_OPEN_SETTINGS_ACTION_ID); }} text='Open settings' />
</div>
)}
</ScrollToBottomContainer>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className='mt-1 flex flex-wrap gap-1 px-1'>
{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 (
<span
key={idx}
className='inline-flex items-center gap-1 px-2 py-0.5 rounded border border-void-border-3 bg-void-bg-1 text-void-fg-2 text-[11px]'
title={tooltipText}
aria-label={tooltipText}
>
<span className='opacity-80'>{sel.type === 'Folder' ? 'Folder' : 'File'}</span>
<span className='text-void-fg-1'>{name}</span>
{rangeLabel && <span className='opacity-70'>{rangeLabel}</span>}
<button
type="button"
className='btn btn-icon btn-ghost ml-1 text-void-fg-3 hover:text-void-fg-1'
onClick={onRemoveLast}
aria-label={`Remove ${name}`}
>
×
</button>
</span>
);
})}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -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 };
};
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading