diff --git a/packages/ai-chat-ui/src/browser/ai-chat-ui-contribution.ts b/packages/ai-chat-ui/src/browser/ai-chat-ui-contribution.ts index 7ec0c24366b32..7453e51dc22a1 100644 --- a/packages/ai-chat-ui/src/browser/ai-chat-ui-contribution.ts +++ b/packages/ai-chat-ui/src/browser/ai-chat-ui-contribution.ts @@ -31,8 +31,7 @@ import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/li import { ChatViewWidget } from './chat-view-widget'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { SecondaryWindowHandler } from '@theia/core/lib/browser/secondary-window-handler'; -import { formatDistance } from 'date-fns'; -import * as locales from 'date-fns/locale'; +import { formatTimeAgo } from './chat-date-utils'; import { AI_SHOW_SETTINGS_COMMAND, AIActivationService, ENABLE_AI_CONTEXT_KEY } from '@theia/ai-core/lib/browser'; import { ChatNodeToolbarCommands } from './chat-node-toolbar-action-contribution'; import { isEditableRequestNode, isResponseNode, type EditableRequestNode, type ResponseNode } from './chat-tree-view'; @@ -371,7 +370,7 @@ export class AIChatContribution extends AbstractViewContribution return ({ label, - description: formatDistance(new Date(session.lastDate), new Date(), { addSuffix: false, locale: getDateFnsLocale() }), + description: formatTimeAgo(session.lastDate, false), detail: session.firstRequestText || (session.isActive ? undefined : nls.localize('theia/ai/chat-ui/persistedSession', 'Persisted session (click to restore)')), id: session.id, buttons: [AIChatContribution.RENAME_CHAT_BUTTON, AIChatContribution.REMOVE_CHAT_BUTTON] @@ -593,8 +592,3 @@ export class AIChatContribution extends AbstractViewContribution return { openedIds: openedContextIds, activeId: activeContextId }; } } - -function getDateFnsLocale(): locales.Locale { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return nls.locale ? (locales as any)[nls.locale] ?? locales.enUS : locales.enUS; -} diff --git a/packages/ai-chat-ui/src/browser/ai-chat-ui-frontend-module.ts b/packages/ai-chat-ui/src/browser/ai-chat-ui-frontend-module.ts index 02e513cdfbab7..1169b8219b175 100644 --- a/packages/ai-chat-ui/src/browser/ai-chat-ui-frontend-module.ts +++ b/packages/ai-chat-ui/src/browser/ai-chat-ui-frontend-module.ts @@ -47,7 +47,7 @@ import { TypeDocSymbolSelectionResolver, } from './chat-response-renderer/ai-selection-resolver'; import { QuestionPartRenderer } from './chat-response-renderer/question-part-renderer'; -import { createChatViewTreeWidget } from './chat-tree-view'; +import { createChatViewTreeWidget, ChatWelcomeMessageProvider } from './chat-tree-view'; import { ChatViewTreeWidget } from './chat-tree-view/chat-view-tree-widget'; import { ChatViewMenuContribution } from './chat-view-contribution'; import { ChatViewLanguageContribution } from './chat-view-language-contribution'; @@ -81,6 +81,7 @@ export default new ContainerModule((bind, _unbind, _isBound, rebind) => { bind(KeybindingContribution).toService(ChatFocusContribution); bindContributionProvider(bind, ChatResponsePartRenderer); + bindContributionProvider(bind, ChatWelcomeMessageProvider); bindChatViewWidget(bind); diff --git a/packages/ai-chat-ui/src/browser/chat-date-utils.ts b/packages/ai-chat-ui/src/browser/chat-date-utils.ts new file mode 100644 index 0000000000000..8668a5f95bfed --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-date-utils.ts @@ -0,0 +1,39 @@ +// ***************************************************************************** +// Copyright (C) 2025 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { nls } from '@theia/core'; +import { formatDistance } from 'date-fns'; +import * as locales from 'date-fns/locale'; + +/** + * Returns the date-fns locale matching the current Theia locale. + */ +export function getDateFnsLocale(): locales.Locale { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return nls.locale ? (locales as any)[nls.locale] ?? locales.enUS : locales.enUS; +} + +/** + * Formats a timestamp as a human-readable relative time string (e.g., "2 hours ago"). + * @param timestamp - The timestamp in milliseconds + * @param addSuffix - Whether to add "ago" suffix (default: true) + */ +export function formatTimeAgo(timestamp: number, addSuffix: boolean = true): string { + return formatDistance(new Date(timestamp), new Date(), { + addSuffix, + locale: getDateFnsLocale() + }); +} diff --git a/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx index 89e758c35c427..98c567c4c754a 100644 --- a/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx @@ -53,7 +53,6 @@ import { inject, injectable, named, - optional, postConstruct } from '@theia/core/shared/inversify'; import * as React from '@theia/core/shared/react'; @@ -96,6 +95,8 @@ export interface ChatWelcomeMessageProvider { readonly modelRequirementBypassed?: boolean; readonly defaultAgent?: string; readonly onStateChanged?: Event; + /** Optional priority for rendering order. Higher values render first. Default: 0 */ + readonly priority?: number; } @injectable() @@ -125,8 +126,8 @@ export class ChatViewTreeWidget extends TreeWidget { @inject(HoverService) protected hoverService: HoverService; - @inject(ChatWelcomeMessageProvider) @optional() - protected welcomeMessageProvider?: ChatWelcomeMessageProvider; + @inject(ContributionProvider) @named(ChatWelcomeMessageProvider) + protected readonly welcomeMessageProviders: ContributionProvider; @inject(AIChatTreeInputFactory) protected inputWidgetFactory: AIChatTreeInputFactory; @@ -228,12 +229,14 @@ export class ChatViewTreeWidget extends TreeWidget { }) ]); - if (this.welcomeMessageProvider?.onStateChanged) { - this.toDispose.push( - this.welcomeMessageProvider.onStateChanged(() => { - this.update(); - }) - ); + for (const provider of this.welcomeMessageProviders.getContributions()) { + if (provider.onStateChanged) { + this.toDispose.push( + provider.onStateChanged(() => { + this.update(); + }) + ); + } } // Initialize lastScrollTop with current scroll position @@ -395,12 +398,47 @@ export class ChatViewTreeWidget extends TreeWidget { this.update(); } + /** + * Returns providers sorted by priority (highest first). + */ + protected getSortedWelcomeMessageProviders(): ChatWelcomeMessageProvider[] { + return this.welcomeMessageProviders.getContributions() + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); + } + + /** + * Returns the highest-priority provider for backward-compatible property access. + */ + protected get welcomeMessageProvider(): ChatWelcomeMessageProvider | undefined { + return this.getSortedWelcomeMessageProviders()[0]; + } + protected renderDisabledMessage(): React.ReactNode { - return this.welcomeMessageProvider?.renderDisabledMessage?.() ?? <>; + const providers = this.getSortedWelcomeMessageProviders(); + const nodes = providers + .map(p => p.renderDisabledMessage?.()) + .filter((node): node is React.ReactNode => node !== undefined); + return nodes.length > 0 + ?
{nodes}
+ : <>; } protected renderWelcomeMessage(): React.ReactNode { - return this.welcomeMessageProvider?.renderWelcomeMessage?.() ?? <>; + const providers = this.getSortedWelcomeMessageProviders(); + const nodes = providers + .map(p => p.renderWelcomeMessage?.()) + .filter((node): node is React.ReactNode => node !== undefined); + if (nodes.length === 0) { + return <>; + } + const withDividers: React.ReactNode[] = []; + nodes.forEach((node, index) => { + if (index > 0) { + withDividers.push(
); + } + withDividers.push(node); + }); + return
{withDividers}
; } protected mapRequestToNode(branch: ChatHierarchyBranch): RequestNode { diff --git a/packages/ai-chat-ui/src/browser/chat-view-widget.tsx b/packages/ai-chat-ui/src/browser/chat-view-widget.tsx index c1e58565086a5..058d0815fbdde 100644 --- a/packages/ai-chat-ui/src/browser/chat-view-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-view-widget.tsx @@ -13,11 +13,11 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { CommandService, deepClone, Emitter, Event, MessageService, PreferenceService, URI } from '@theia/core'; +import { CommandService, ContributionProvider, deepClone, Emitter, Event, MessageService, PreferenceService, URI } from '@theia/core'; import { ChatRequest, ChatRequestModel, ChatService, ChatSession, isActiveSessionChangedEvent, MutableChatModel } from '@theia/ai-chat'; import { BaseWidget, codicon, ExtractableWidget, Message, PanelLayout, StatefulWidget } from '@theia/core/lib/browser'; import { nls } from '@theia/core/lib/common/nls'; -import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify'; +import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify'; import { AIChatInputWidget } from './chat-input-widget'; import { ChatViewTreeWidget, ChatWelcomeMessageProvider } from './chat-tree-view/chat-view-tree-widget'; import { AIActivationService } from '@theia/ai-core/lib/browser/ai-activation-service'; @@ -63,8 +63,8 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta @inject(FrontendLanguageModelRegistry) protected readonly languageModelRegistry: FrontendLanguageModelRegistry; - @inject(ChatWelcomeMessageProvider) @optional() - protected readonly welcomeProvider?: ChatWelcomeMessageProvider; + @inject(ContributionProvider) @named(ChatWelcomeMessageProvider) + protected readonly welcomeMessageProviders: ContributionProvider; protected chatSession: ChatSession; @@ -135,11 +135,13 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta }) ); - if (this.welcomeProvider?.onStateChanged) { - this.toDispose.push(this.welcomeProvider.onStateChanged(() => { - this.updateInputEnabledState(); - this.update(); - })); + for (const provider of this.welcomeMessageProviders.getContributions()) { + if (provider.onStateChanged) { + this.toDispose.push(provider.onStateChanged(() => { + this.updateInputEnabledState(); + this.update(); + })); + } } this.toDispose.push(this.progressBarFactory({ container: this.node, insertMode: 'prepend', locationId: 'ai-chat' })); @@ -151,12 +153,21 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta this.treeWidget.setEnabled(this.activationService.isActive); } + /** + * Returns the highest-priority welcome message provider for backward-compatible property access. + */ + protected get welcomeProvider(): ChatWelcomeMessageProvider | undefined { + return this.welcomeMessageProviders.getContributions() + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))[0]; + } + protected async shouldEnableInput(): Promise { - if (!this.welcomeProvider) { + const provider = this.welcomeProvider; + if (!provider) { return true; } const hasReadyModels = await this.hasReadyLanguageModels(); - const modelRequirementBypassed = this.welcomeProvider.modelRequirementBypassed ?? false; + const modelRequirementBypassed = provider.modelRequirementBypassed ?? false; return hasReadyModels || modelRequirementBypassed; } diff --git a/packages/ai-chat/src/common/ai-chat-preferences.ts b/packages/ai-chat/src/common/ai-chat-preferences.ts index 16724fe7032fd..2c63a645c7949 100644 --- a/packages/ai-chat/src/common/ai-chat-preferences.ts +++ b/packages/ai-chat/src/common/ai-chat-preferences.ts @@ -22,6 +22,7 @@ export const PIN_CHAT_AGENT_PREF = 'ai-features.chat.pinChatAgent'; export const BYPASS_MODEL_REQUIREMENT_PREF = 'ai-features.chat.bypassModelRequirement'; export const PERSISTED_SESSION_LIMIT_PREF = 'ai-features.chat.persistedSessionLimit'; export const SESSION_STORAGE_PREF = 'ai-features.chat.sessionStorageScope'; +export const WELCOME_SCREEN_SESSIONS_PREF = 'ai-features.chat.welcomeScreenSessions'; export type SessionStorageScope = 'workspace' | 'global'; @@ -58,6 +59,16 @@ export const aiChatPreferences: PreferenceSchema = { minimum: -1, title: AI_CORE_PREFERENCES_TITLE, }, + [WELCOME_SCREEN_SESSIONS_PREF]: { + type: 'number', + description: nls.localize('theia/ai/chat/welcomeScreenSessions/description', + 'Number of rows of recent chat sessions to display on the welcome screen. The number of visible sessions depends ' + + 'on the available width. Set to 0 to hide the recent chats section.'), + default: 3, + minimum: 0, + maximum: 10, + title: AI_CORE_PREFERENCES_TITLE, + }, [SESSION_STORAGE_PREF]: { type: 'string', enum: ['workspace', 'global'] satisfies SessionStorageScope[], diff --git a/packages/ai-ide/src/browser/chat-sessions-welcome-message-provider.tsx b/packages/ai-ide/src/browser/chat-sessions-welcome-message-provider.tsx new file mode 100644 index 0000000000000..8579a23143a48 --- /dev/null +++ b/packages/ai-ide/src/browser/chat-sessions-welcome-message-provider.tsx @@ -0,0 +1,198 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ChatWelcomeMessageProvider } from '@theia/ai-chat-ui/lib/browser/chat-tree-view'; +import { formatTimeAgo } from '@theia/ai-chat-ui/lib/browser/chat-date-utils'; +import { ChatService, ChatSessionMetadata } from '@theia/ai-chat'; +import { PERSISTED_SESSION_LIMIT_PREF, SESSION_STORAGE_PREF, WELCOME_SCREEN_SESSIONS_PREF } from '@theia/ai-chat/lib/common/ai-chat-preferences'; +import { AI_CHAT_SHOW_CHATS_COMMAND } from '@theia/ai-chat-ui/lib/browser/chat-view-commands'; +import { CommandRegistry, Emitter, Event, PreferenceService } from '@theia/core'; +import { Card, codicon } from '@theia/core/lib/browser'; +import { nls } from '@theia/core/lib/common/nls'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; + +interface SessionCardsGridProps { + sessions: ChatSessionMetadata[]; + maxRows: number; + renderCard: (session: ChatSessionMetadata) => React.ReactNode; +} + +function SessionCardsGrid({ sessions, maxRows, renderCard }: SessionCardsGridProps): React.ReactElement { + // eslint-disable-next-line no-null/no-null + const gridRef = React.useRef(null); + const [columns, setColumns] = React.useState(1); + + const detectColumns = React.useCallback(() => { + const el = gridRef.current; + if (!el) { + return; + } + const trackStr = getComputedStyle(el).gridTemplateColumns; + const cols = trackStr && trackStr !== 'none' ? trackStr.split(' ').length : 1; + setColumns(prev => prev !== cols ? cols : prev); + }, []); + + // Detect columns synchronously before first paint to avoid flash + React.useLayoutEffect(() => { + detectColumns(); + }, [detectColumns]); + + // Track subsequent resizes + React.useEffect(() => { + const el = gridRef.current; + if (!el) { + return; + } + const observer = new ResizeObserver(detectColumns); + observer.observe(el); + return () => observer.disconnect(); + }, [detectColumns]); + + const maxVisible = maxRows * columns; + const visibleSessions = sessions.slice(0, maxVisible); + + return ( +
+ {visibleSessions.map(renderCard)} +
+ ); +} + +@injectable() +export class ChatSessionsWelcomeMessageProvider implements ChatWelcomeMessageProvider { + + readonly priority = 50; + + @inject(ChatService) + protected readonly chatService: ChatService; + + @inject(CommandRegistry) + protected readonly commandRegistry: CommandRegistry; + + @inject(PreferenceService) + protected readonly preferenceService: PreferenceService; + + protected _sessions: ChatSessionMetadata[] = []; + protected _loading = false; + + protected readonly onStateChangedEmitter = new Emitter(); + readonly onStateChanged: Event = this.onStateChangedEmitter.event; + + @postConstruct() + protected init(): void { + this.loadSessions(); + this.chatService.onSessionEvent(() => this.loadSessions()); + this.preferenceService.onPreferenceChanged(e => { + if (e.preferenceName === PERSISTED_SESSION_LIMIT_PREF || e.preferenceName === SESSION_STORAGE_PREF) { + this.loadSessions(); + } else if (e.preferenceName === WELCOME_SCREEN_SESSIONS_PREF) { + this.onStateChangedEmitter.fire(); + } + }); + } + + protected async loadSessions(): Promise { + if (!this.isPersistenceEnabled()) { + this._sessions = []; + this.onStateChangedEmitter.fire(); + return; + } + + // Check if there are any persisted sessions without initializing storage + const hasSessions = await this.chatService.hasPersistedSessions(); + if (!hasSessions) { + this._sessions = []; + this.onStateChangedEmitter.fire(); + return; + } + + this._loading = true; + this.onStateChangedEmitter.fire(); + + try { + const index = await this.chatService.getPersistedSessions(); + this._sessions = Object.values(index) + .sort((a, b) => b.saveDate - a.saveDate); + } catch (error) { + console.error('Failed to load persisted sessions:', error); + this._sessions = []; + } finally { + this._loading = false; + this.onStateChangedEmitter.fire(); + } + } + + protected isPersistenceEnabled(): boolean { + const limit = this.preferenceService.get(PERSISTED_SESSION_LIMIT_PREF, 25); + return limit !== 0; + } + + protected getMaxRows(): number { + return this.preferenceService.get(WELCOME_SCREEN_SESSIONS_PREF, 3); + } + + renderWelcomeMessage(): React.ReactNode { + const maxRows = this.getMaxRows(); + if (!this.isPersistenceEnabled() || maxRows === 0 || this._sessions.length === 0) { + return undefined; + } + return this.renderSessionsSection(); + } + + protected renderSessionsSection(): React.ReactNode { + const maxRows = this.getMaxRows(); + + return ( +
+
+

+ {nls.localize('theia/ai/ide/recentChats', 'Recent Chats')} +

+ + +
+
+ ); + } + + protected renderSessionCard = (session: ChatSessionMetadata): React.ReactNode => ( + this.handleSessionCardClick(session.sessionId)} + /> + ); + + protected handleSessionCardClick = async (sessionId: string): Promise => { + await this.chatService.getOrRestoreSession(sessionId); + this.chatService.setActiveSession(sessionId, { focus: true }); + }; + + protected handleBrowseAllChats = (): void => { + this.commandRegistry.executeCommand(AI_CHAT_SHOW_CHATS_COMMAND.id); + }; +} diff --git a/packages/ai-ide/src/browser/frontend-module.ts b/packages/ai-ide/src/browser/frontend-module.ts index e5572c1cdcfbc..81ee5bcff1513 100644 --- a/packages/ai-ide/src/browser/frontend-module.ts +++ b/packages/ai-ide/src/browser/frontend-module.ts @@ -79,6 +79,7 @@ import { TemplatePreferenceContribution } from './template-preference-contributi import { AIMCPConfigurationWidget } from './ai-configuration/mcp-configuration-widget'; import { ChatWelcomeMessageProvider } from '@theia/ai-chat-ui/lib/browser/chat-tree-view'; import { IdeChatWelcomeMessageProvider } from './ide-chat-welcome-message-provider'; +import { ChatSessionsWelcomeMessageProvider } from './chat-sessions-welcome-message-provider'; import { DefaultChatAgentRecommendationService } from './default-chat-agent-recommendation-service'; import { AITokenUsageConfigurationWidget } from './ai-configuration/token-usage-configuration-widget'; import { AISkillsConfigurationWidget } from './ai-configuration/skills-configuration-widget'; @@ -164,6 +165,7 @@ export default new ContainerModule((bind, _unbind, _isBound, rebind) => { bind(ChatAgent).toService(CommandChatAgent); bind(ChatWelcomeMessageProvider).to(IdeChatWelcomeMessageProvider).inSingletonScope(); + bind(ChatWelcomeMessageProvider).to(ChatSessionsWelcomeMessageProvider).inSingletonScope(); bind(ChatAgentRecommendationService).to(DefaultChatAgentRecommendationService).inSingletonScope(); bindToolProvider(GetWorkspaceFileList, bind); diff --git a/packages/ai-ide/src/browser/ide-chat-welcome-message-provider.tsx b/packages/ai-ide/src/browser/ide-chat-welcome-message-provider.tsx index 612305f5d8fae..b486d62b1663d 100644 --- a/packages/ai-ide/src/browser/ide-chat-welcome-message-provider.tsx +++ b/packages/ai-ide/src/browser/ide-chat-welcome-message-provider.tsx @@ -63,6 +63,8 @@ const TheiaIdeAiLogo = ({ width = 200, height = 200, className = '' }) => @injectable() export class IdeChatWelcomeMessageProvider implements ChatWelcomeMessageProvider { + readonly priority = 100; + @inject(MarkdownRenderer) protected readonly markdownRenderer: MarkdownRenderer; @@ -197,7 +199,7 @@ export class IdeChatWelcomeMessageProvider implements ChatWelcomeMessageProvider } protected renderWelcomeScreen(): React.ReactNode { - return
+ return
; } - return
+ return
this.chatAgentService.getAgent(agent.id) !== undefined); - return
+ return
void; + /** Additional CSS class */ + className?: string; + /** Child content */ + children?: React.ReactNode; + /** Maximum number of lines for title (default: 4) */ + maxTitleLines?: number; + /** Tooltip for title */ + titleTooltip?: string; +} + +/** + * A reusable component for presentation of a card providing a capsule summary of some + * data, article, or other object. Cards provide interaction behaviour when the `onClick` + * call-back prop is supplied. + */ +export const Card = React.memo(function Card(props: CardProps): React.ReactElement { + const { + icon, + title, + subtitle, + onClick, + className, + children, + maxTitleLines = 4, + titleTooltip + } = props; + + const isInteractive = onClick !== undefined; + + const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { + if (onClick && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onClick(); + } + }, [onClick]); + + const cardClasses = [ + 'theia-Card', + isInteractive && 'theia-Card-interactive', + className + ].filter(Boolean).join(' '); + + const titleStyle: React.CSSProperties = { + WebkitLineClamp: maxTitleLines + }; + + return ( +
+ {icon && ( +
+ )} +
+
+ {title} +
+ {subtitle && ( +
+ {subtitle} +
+ )} + {children} +
+
+ ); +}); diff --git a/packages/core/src/browser/components/index.ts b/packages/core/src/browser/components/index.ts new file mode 100644 index 0000000000000..a5b27fe713f54 --- /dev/null +++ b/packages/core/src/browser/components/index.ts @@ -0,0 +1,17 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +export * from './card'; diff --git a/packages/core/src/browser/index.ts b/packages/core/src/browser/index.ts index 2757003b5703b..17e65f4840793 100644 --- a/packages/core/src/browser/index.ts +++ b/packages/core/src/browser/index.ts @@ -53,3 +53,4 @@ export * from './widget-status-bar-service'; export * from './badges'; export * from './markdown-rendering/markdown-renderer'; export * from './markdown-rendering/markdown'; +export * from './components'; diff --git a/packages/core/src/browser/style/card.css b/packages/core/src/browser/style/card.css new file mode 100644 index 0000000000000..b1715c97ce794 --- /dev/null +++ b/packages/core/src/browser/style/card.css @@ -0,0 +1,72 @@ +/******************************************************************************** + * Copyright (C) 2026 EclipseSource GmbH. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 + ********************************************************************************/ + +/* Base card container */ +.theia-Card { + display: flex; + align-items: center; + padding: calc(var(--theia-ui-padding) * 1.5) calc(var(--theia-ui-padding) * 2); + background-color: var(--theia-editor-background); + border: 1px solid var(--theia-dropdown-border); + border-radius: 10px; + text-align: left; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +/* Interactive card variant for clickable actions. */ +.theia-Card-interactive { + cursor: pointer; + transition: background-color 0.2s, border-color 0.2s, box-shadow 0.2s; +} + +.theia-Card-interactive:hover { + background-color: color-mix(in srgb, var(--theia-list-hoverBackground) 50%, var(--theia-editor-background)); + border-color: var(--theia-focusBorder); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12); +} + +.theia-Card-interactive:focus { + outline: 1px solid var(--theia-focusBorder); + outline-offset: -1px; +} + +.theia-Card-icon { + margin-right: calc(var(--theia-ui-padding) * 1.5); + color: var(--theia-descriptionForeground); + flex-shrink: 0; +} + +.theia-Card-content { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.theia-Card-title { + font-weight: 500; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; +} + +.theia-Card-subtitle { + font-size: var(--theia-ui-font-size0); + color: var(--theia-descriptionForeground); + margin-top: calc(var(--theia-ui-padding) / 2); +} diff --git a/packages/core/src/browser/style/index.css b/packages/core/src/browser/style/index.css index bde37b7cc04fd..ef99f21f02c1a 100644 --- a/packages/core/src/browser/style/index.css +++ b/packages/core/src/browser/style/index.css @@ -353,3 +353,4 @@ button.secondary[disabled], @import "./tooltip.css"; @import "./split-widget.css"; @import "./symbol-icon.css"; +@import "./card.css";