From 6371ba72c68836a70d226037ddc505842d18fed5 Mon Sep 17 00:00:00 2001 From: Tajudeen Date: Wed, 22 Jul 2026 19:01:31 +0100 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Phase=201=20Sprint=201=20=E2=80=94?= =?UTF-8?q?=20composer=20tabs=20and=20thread=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ComposerTabs and ThreadHeader chrome wired to existing openTabs/ switchToTab/closeTab APIs, unify the chat shell layout, and extract getThreadTabLabel for tab labels with unit tests. Co-authored-by: Cursor --- .../react/src/sidebar-tsx/SidebarChat.tsx | 26 ++++-- .../src/sidebar-tsx/SidebarThreadSelector.tsx | 2 +- .../src/sidebar-tsx/chrome/ComposerTabs.tsx | 82 +++++++++++++++++++ .../src/sidebar-tsx/chrome/ThreadHeader.tsx | 65 +++++++++++++++ .../contrib/cortexide/common/threadTitle.ts | 34 ++++++++ .../cortexide/test/common/threadTitle.test.ts | 27 ++++++ 6 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/chrome/ComposerTabs.tsx create mode 100644 src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/chrome/ThreadHeader.tsx create mode 100644 src/vs/workbench/contrib/cortexide/common/threadTitle.ts create mode 100644 src/vs/workbench/contrib/cortexide/test/common/threadTitle.test.ts diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarChat.tsx index f170a61f8d65..dbc0fea44075 100644 --- a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -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'; @@ -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, @@ -5031,11 +5034,22 @@ export const SidebarChat = () => { return ( - - {isLandingPage ? - landingPageContent - : threadPageContent} - +
+ + setShowHistory(v => !v)} + /> + {showHistory && ( + +
+ +
+
+ )} +
+ {isLandingPage ? landingPageContent : threadPageContent} +
+
) } diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx index 8ef6cf934278..de9767eba09b 100644 --- a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx @@ -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)} diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/chrome/ComposerTabs.tsx b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/chrome/ComposerTabs.tsx new file mode 100644 index 000000000000..ab4c8e82edf2 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/chrome/ComposerTabs.tsx @@ -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 ( +
+
+ {tabs.map(threadId => { + const thread = allThreads[threadId]; + const active = threadId === currentThreadId; + const label = getThreadTabLabel(thread); + const running = isRunning(threadId); + + return ( +
chatThreadsService.switchToTab(threadId)} + title={label} + > + {(running === 'LLM' || running === 'tool' || running === 'preparing') && ( + + )} + {label} + +
+ ); + })} +
+ +
+ ); +}; diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/chrome/ThreadHeader.tsx b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/chrome/ThreadHeader.tsx new file mode 100644 index 000000000000..52300bb8b2fc --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/chrome/ThreadHeader.tsx @@ -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 ( +
+ + {title} + +
+ {hasHistory && ( + + )} + + +
+
+ ); +}; diff --git a/src/vs/workbench/contrib/cortexide/common/threadTitle.ts b/src/vs/workbench/contrib/cortexide/common/threadTitle.ts new file mode 100644 index 000000000000..c6320c4d69b9 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/common/threadTitle.ts @@ -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)}…`; +}; diff --git a/src/vs/workbench/contrib/cortexide/test/common/threadTitle.test.ts b/src/vs/workbench/contrib/cortexide/test/common/threadTitle.test.ts new file mode 100644 index 000000000000..464d3800d325 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/test/common/threadTitle.test.ts @@ -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); + }); +});