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 @@ -16,6 +16,8 @@ import { ErrorDisplay } from './ErrorDisplay.js';
import { BlockCode, TextAreaFns, VoidCustomDropdownBox, VoidInputBox2, VoidSlider, VoidSwitch, VoidDiffEditor } from '../util/inputs.js';
import { ModelDropdown, } from '../settings/ModelDropdown.js';
import { PastThreadsList } from './SidebarThreadSelector.js';
import { ComposerTabs } from './chrome/ComposerTabs.js';
import { ThreadHeader } from './chrome/ThreadHeader.js';
import { CORTEXIDE_CTRL_L_ACTION_ID } from '../../../actionIDs.js';
import { CORTEXIDE_OPEN_SETTINGS_ACTION_ID } from '../../../cortexideSettingsPane.js';
import { ChatMode, displayInfoOfProviderName, FeatureName, isFeatureNameDisabled, isValidProviderModelSelection } from '../../../../../../../workbench/contrib/cortexide/common/cortexideSettingsTypes.js';
Expand Down Expand Up @@ -4051,6 +4053,7 @@ export const SidebarChat = () => {
const [instructionsAreEmpty, setInstructionsAreEmpty] = useState(!initVal)

// Image attachments management
const [showHistory, setShowHistory] = useState(false);
const {
attachments: imageAttachments,
addImages: addImagesRaw,
Expand Down Expand Up @@ -5031,11 +5034,22 @@ export const SidebarChat = () => {


return (
<Fragment key={threadId} // force rerender when change thread
>
{isLandingPage ?
landingPageContent
: threadPageContent}
</Fragment>
<div key={threadId} className="w-full h-full flex flex-col overflow-hidden">
<ComposerTabs />
<ThreadHeader
showHistory={showHistory}
onToggleHistory={() => setShowHistory(v => !v)}
/>
{showHistory && (
<ErrorBoundary>
<div className="shrink-0 max-h-[40%] overflow-y-auto px-3 py-2 border-b border-void-border-3 bg-void-bg-2/50">
<PastThreadsList />
</div>
</ErrorBoundary>
)}
<div className="flex-1 min-h-0 overflow-hidden">
{isLandingPage ? landingPageContent : threadPageContent}
</div>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ const PastThreadElement = ({ pastThread, idx, hoveredIdx, setHoveredIdx, isRunni
group px-3 py-2 rounded-xl border border-void-border-3/70 bg-void-bg-1/40 hover:bg-void-bg-2/70 cursor-pointer text-sm text-void-fg-1 transition-all duration-150 ease-out shadow-[0_8px_20px_rgba(0,0,0,0.35)] hover:-translate-y-0.5
`}
onClick={() => {
chatThreadsService.switchToThread(pastThread.id);
chatThreadsService.switchToTab(pastThread.id);
}}
onMouseEnter={() => setHoveredIdx(idx)}
onMouseLeave={() => setHoveredIdx(null)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/

import { LoaderCircle, Plus, X } from 'lucide-react';
import { useAccessor, useChatThreadsState, useFullChatThreadsStreamState } from '../../util/services.js';
import type { IsRunningType } from '../../../../chatThreadService.js';
import { getThreadTabLabel } from '../../../../common/threadTitle.js';

const MAX_VISIBLE_TABS = 12;

export const ComposerTabs = () => {
const accessor = useAccessor();
const chatThreadsService = accessor.get('IChatThreadService');
const { openTabs, currentThreadId, allThreads } = useChatThreadsState();
const streamState = useFullChatThreadsStreamState();

const tabs = openTabs.slice(0, MAX_VISIBLE_TABS);

const isRunning = (threadId: string): IsRunningType | undefined =>
streamState[threadId]?.isRunning;

return (
<div
className="flex items-center gap-0.5 px-2 py-1 border-b border-void-border-3 bg-void-bg-2/80 shrink-0 min-h-[36px]"
role="tablist"
aria-label="Chat threads"
>
<div className="flex items-center gap-0.5 flex-1 min-w-0 overflow-x-auto void-scrollbar">
{tabs.map(threadId => {
const thread = allThreads[threadId];
const active = threadId === currentThreadId;
const label = getThreadTabLabel(thread);
const running = isRunning(threadId);

return (
<div
key={threadId}
role="tab"
aria-selected={active}
className={`
group flex items-center gap-1 max-w-[160px] shrink-0 pl-2.5 pr-1 py-1 rounded-md text-xs cursor-pointer
border transition-colors duration-100
${active
? 'bg-void-bg-1 border-void-border-2 text-void-fg-1 shadow-sm'
: 'bg-transparent border-transparent text-void-fg-3 hover:text-void-fg-2 hover:bg-void-bg-1/50'}
`}
onClick={() => chatThreadsService.switchToTab(threadId)}
title={label}
>
{(running === 'LLM' || running === 'tool' || running === 'preparing') && (
<LoaderCircle className="size-3 shrink-0 animate-spin" aria-hidden />
)}
<span className="truncate">{label}</span>
<button
type="button"
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-void-bg-3 text-void-fg-3 hover:text-void-fg-1 void-focus-ring"
aria-label={`Close ${label}`}
onClick={(e) => {
e.stopPropagation();
chatThreadsService.closeTab(threadId);
}}
>
<X className="size-3" />
</button>
</div>
);
})}
</div>
<button
type="button"
className="shrink-0 p-1.5 rounded-md text-void-fg-3 hover:text-void-fg-1 hover:bg-void-bg-1 void-focus-ring"
aria-label="New chat"
title="New chat"
onClick={() => chatThreadsService.openNewThread()}
>
<Plus className="size-4" />
</button>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/

import { History, Settings2 } from 'lucide-react';
import { useAccessor, useChatThreadsState } from '../../util/services.js';
import { CORTEXIDE_OPEN_SETTINGS_ACTION_ID } from '../../../../cortexideSettingsPane.js';
import { getThreadTabLabel } from '../../../../common/threadTitle.js';

type ThreadHeaderProps = {
showHistory: boolean;
onToggleHistory: () => void;
};

export const ThreadHeader = ({ showHistory, onToggleHistory }: ThreadHeaderProps) => {
const accessor = useAccessor();
const commandService = accessor.get('ICommandService');
const chatThreadsService = accessor.get('IChatThreadService');
const { currentThreadId, allThreads } = useChatThreadsState();

const thread = allThreads[currentThreadId];
const title = getThreadTabLabel(thread);
const hasHistory = Object.keys(allThreads).some(
id => (allThreads[id]?.messages.length ?? 0) > 0,
);

return (
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-void-border-3/50 shrink-0 min-h-[32px]">
<span className="text-xs font-medium text-void-fg-2 truncate min-w-0" title={title}>
{title}
</span>
<div className="flex items-center gap-1 shrink-0">
{hasHistory && (
<button
type="button"
className={`p-1.5 rounded-md void-focus-ring ${showHistory ? 'bg-void-bg-1 text-void-fg-1' : 'text-void-fg-3 hover:text-void-fg-1 hover:bg-void-bg-1/60'}`}
aria-label="Toggle chat history"
aria-pressed={showHistory}
title="Chat history"
onClick={onToggleHistory}
>
<History className="size-3.5" />
</button>
)}
<button
type="button"
className="px-2 py-1 rounded-md text-xs text-void-fg-2 hover:text-void-fg-1 hover:bg-void-bg-1 void-focus-ring"
onClick={() => chatThreadsService.openNewThread()}
>
New chat
</button>
<button
type="button"
className="p-1.5 rounded-md text-void-fg-3 hover:text-void-fg-1 hover:bg-void-bg-1 void-focus-ring"
aria-label="Open CortexIDE settings"
title="Settings"
onClick={() => commandService.executeCommand(CORTEXIDE_OPEN_SETTINGS_ACTION_ID)}
>
<Settings2 className="size-3.5" />
</button>
</div>
</div>
);
};
34 changes: 34 additions & 0 deletions src/vs/workbench/contrib/cortexide/common/threadTitle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/

const MAX_TAB_LABEL = 32;

/** Minimal thread shape for tab labels (no browser service import). */
export type ThreadLabelSource = {
messages: { role: string; displayContent?: string }[];
};

/** Short label for composer tabs and headers (first user message or "New chat"). */
export const getThreadTabLabel = (thread: ThreadLabelSource | undefined): string => {
if (!thread) {
return 'New chat';
}
const firstUserIdx = thread.messages.findIndex(m => m.role === 'user');
if (firstUserIdx === -1) {
return 'New chat';
}
const msg = thread.messages[firstUserIdx];
if (msg.role !== 'user') {
return 'New chat';
}
const text = (msg.displayContent || '').trim().replace(/\s+/g, ' ');
if (!text) {
return 'New chat';
}
if (text.length <= MAX_TAB_LABEL) {
return text;
}
return `${text.slice(0, MAX_TAB_LABEL - 1)}…`;
};
27 changes: 27 additions & 0 deletions src/vs/workbench/contrib/cortexide/test/common/threadTitle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import { suite, test } from 'mocha';
import { getThreadTabLabel } from '../../common/threadTitle.js';

suite('getThreadTabLabel', () => {
test('empty thread is "New chat"', () => {
assert.strictEqual(getThreadTabLabel({ messages: [] } as any), 'New chat');
});

test('uses first user message', () => {
assert.strictEqual(getThreadTabLabel({
messages: [{ role: 'user', displayContent: 'Fix the menubar bug' }],
} as any), 'Fix the menubar bug');
});

test('truncates long titles', () => {
const long = 'a'.repeat(40);
const label = getThreadTabLabel({ messages: [{ role: 'user', displayContent: long }] } as any);
assert.ok(label.endsWith('…'));
assert.ok(label.length <= 32);
});
});
Loading