From 88e6483ba17865afd0b16851f518003fd9153690 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Sun, 20 Sep 2026 19:54:23 +0800 Subject: [PATCH] feat(tabs): add opt-in redesigned session tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session tabs above the composer can show the conversation title with a hover close button, open new sessions and conversations picked from history in their own tab instead of replacing the active session, and scroll horizontally instead of squeezing when many sessions are open. The interaction sits behind a new "New session tabs" switch in Settings → Experimental (off by default), so the numbered tabs, the new-conversation button, and the current history behavior stay unchanged unless it is enabled. With the switch on, the new-conversation button and its `New session (in current tab)` command stay out of the way; /clear still resets the current session. Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 19 ++ src/app/settings/settings-storage.ts | 1 + src/core/types/settings.ts | 2 + src/features/chat/chat-view.ts | 61 +++++- src/features/chat/tabs/tab-bar.ts | 175 +++++++++++++++--- src/features/chat/tabs/tab-manager.ts | 52 ++++-- src/features/chat/tabs/types.ts | 2 +- src/features/settings/settings-tab.ts | 14 ++ .../settings/ui/qoder-settings-tab.ts | 15 ++ src/i18n/locales/de.json | 11 +- src/i18n/locales/en.json | 11 +- src/i18n/locales/es.json | 11 +- src/i18n/locales/fr.json | 11 +- src/i18n/locales/ja.json | 11 +- src/i18n/locales/ko.json | 11 +- src/i18n/locales/pt.json | 11 +- src/i18n/locales/ru.json | 11 +- src/i18n/locales/zh-CN.json | 11 +- src/i18n/locales/zh-TW.json | 11 +- src/i18n/types.ts | 7 +- src/main.ts | 4 + src/style/components/input.css | 2 + src/style/components/tabs.css | 107 +++++++++-- tests/helpers/mock-element.ts | 2 + tests/integration/main.test.ts | 2 +- tests/unit/features/chat/chat-view.test.ts | 128 ++++++++++--- tests/unit/features/chat/tabs/tab-bar.test.ts | 150 +++++++++++++++ .../tab-manager-open-conversation.test.ts | 98 ++++++++++ .../features/settings/settings-tab.test.ts | 1 - tests/unit/i18n/locales.test.ts | 5 + 30 files changed, 852 insertions(+), 105 deletions(-) create mode 100644 tests/unit/features/chat/tabs/tab-bar.test.ts create mode 100644 tests/unit/features/chat/tabs/tab-manager-open-conversation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 245a8eb..6308da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ version with its date and start a fresh empty `[Unreleased]` above it. ## [Unreleased] +### Added + +- An opt-in **New session tabs** toggle in Settings → Experimental changes how + the session tabs above the composer behave: + + - Tabs show the conversation title with a close button that appears on hover + instead of a number. + - New sessions and conversations picked from the history list each open in + their own tab, so the session in the active tab is never replaced; once the + tab limit is reached the notice points at the setting that raises it. + - With many sessions the row scrolls instead of squeezing every tab, follows + the active session, and keeps the new-session and history buttons pinned. + - The new-conversation button that reset the active tab, and its + `New session (in current tab)` command, are hidden; `/clear` still resets + the current session. + + The toggle is off by default; off keeps the numbered tabs and the existing + interactions unchanged. + ## [1.0.14] - 2026-09-20 ### Changed diff --git a/src/app/settings/settings-storage.ts b/src/app/settings/settings-storage.ts index a5dde3b..b713cb9 100644 --- a/src/app/settings/settings-storage.ts +++ b/src/app/settings/settings-storage.ts @@ -47,6 +47,7 @@ export const DEFAULT_QODERIAN_SETTINGS: QoderianSettings = { deferMathRenderingDuringStreaming: true, expandFileEditsByDefault: false, chatViewPlacement: 'right-sidebar', + enableSessionTabsRedesign: false, }; export interface SettingsRecoveryNotice { diff --git a/src/core/types/settings.ts b/src/core/types/settings.ts index 9da58c9..388e3a4 100644 --- a/src/core/types/settings.ts +++ b/src/core/types/settings.ts @@ -193,6 +193,8 @@ export interface QoderianSettings { deferMathRenderingDuringStreaming: boolean; expandFileEditsByDefault: boolean; chatViewPlacement: ChatViewPlacement; + /** Experimental: session pills with titles, close buttons, and new-tab semantics. */ + enableSessionTabsRedesign: boolean; // Allow forward-compatible settings fields [key: string]: unknown; diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index 0377442..45b83d3 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -22,7 +22,7 @@ import { } from './tabs/tab'; import { TabBar } from './tabs/tab-bar'; import { TabManager } from './tabs/tab-manager'; -import type { TabData, TabId } from './tabs/types'; +import { DEFAULT_MAX_TABS, type TabData, type TabId } from './tabs/types'; import { CreditsUsageButton } from './ui/credits-usage-button'; type LoadableView = { @@ -262,8 +262,9 @@ export class QoderianView extends ItemView { private buildNavRowContent(): HTMLElement { const wrapper = createDiv({ cls: 'qoderian-input-nav-content' }); - this.tabBarContainerEl = wrapper.createDiv({ cls: 'qoderian-tab-bar-container' }); - this.tabBar = new TabBar(this.tabBarContainerEl, { + const tabBarContainerEl = wrapper.createDiv({ cls: 'qoderian-tab-bar-container' }); + this.tabBarContainerEl = tabBarContainerEl; + this.tabBar = new TabBar(tabBarContainerEl, { onTabClick: (tabId) => this.handleTabClick(tabId), onTabClose: (tabId) => { void this.handleTabClose(tabId); @@ -271,13 +272,15 @@ export class QoderianView extends ItemView { onNewTab: () => { void this.createNewTab().catch(() => new Notice('Failed to create tab')); }, + }, { + isLegacyMode: () => !this.sessionTabsRedesignEnabled(), }); const navActionsEl = wrapper.createDiv({ cls: 'qoderian-input-nav-actions' }); this.newTabButtonEl = navActionsEl.createDiv({ cls: 'qoderian-input-nav-btn qoderian-new-tab-btn' }); setIcon(this.newTabButtonEl, 'square-plus'); - setButtonTooltip(this.newTabButtonEl, t('commands.newTab')); + setButtonTooltip(this.newTabButtonEl, this.newSessionButtonTooltip()); this.newTabButtonEl.addEventListener('click', () => { void this.createNewTab().catch(() => new Notice('Failed to create tab')); }); @@ -286,6 +289,7 @@ export class QoderianView extends ItemView { setIcon(newBtn, 'square-pen'); setButtonTooltip(newBtn, t('nav.newConversation')); this.newConversationButtonEl = newBtn; + newBtn.toggleClass('qoderian-hidden', this.sessionTabsRedesignEnabled()); newBtn.addEventListener('click', () => { void (async () => { await this.tabManager?.createNewConversation(); @@ -398,14 +402,36 @@ export class QoderianView extends ItemView { this.activeInputTabId = null; } + /** Whether the experimental session-tab interaction (own tab per session) is on. */ + private sessionTabsRedesignEnabled(): boolean { + return this.plugin.settings.enableSessionTabsRedesign === true; + } + + private newSessionButtonTooltip(): string { + return this.sessionTabsRedesignEnabled() ? t('nav.newSession') : t('commands.newTab'); + } + /** Refreshes tab controls after settings that affect tab availability change. */ refreshTabControls(): void { this.updateTabBarVisibility(); } + /** Re-renders tab chrome after the experimental session-tab mode changes. */ + refreshSessionTabsMode(): void { + this.newConversationButtonEl?.toggleClass( + 'qoderian-hidden', + this.sessionTabsRedesignEnabled(), + ); + if (this.newTabButtonEl) { + setButtonTooltip(this.newTabButtonEl, this.newSessionButtonTooltip()); + } + this.updateTabBarVisibility(); + this.updateTabBar(); + } + /** Re-applies locale-dependent static text after a language change. */ refreshLocalizedChrome(): void { - if (this.newTabButtonEl) setButtonTooltip(this.newTabButtonEl, t('commands.newTab')); + if (this.newTabButtonEl) setButtonTooltip(this.newTabButtonEl, this.newSessionButtonTooltip()); if (this.newConversationButtonEl) { setButtonTooltip(this.newConversationButtonEl, t('nav.newConversation')); } @@ -446,8 +472,7 @@ export class QoderianView extends ItemView { async createNewTab(): Promise { const tab = await this.tabManager?.createTab(); if (!tab) { - const maxTabs = this.plugin.settings.maxTabs ?? 3; - new Notice(`Maximum ${maxTabs} tabs allowed`); + this.noticeSessionLimit(); this.updateTabBarVisibility(); return; } @@ -474,6 +499,8 @@ export class QoderianView extends ItemView { private updateTabBarVisibility(): void { if (!this.tabBarContainerEl || !this.tabManager) return; + // Session pills stay visible for a single session too. + if (this.sessionTabsRedesignEnabled()) return; const tabCount = this.tabManager.getTabCount(); const showTabBar = tabCount >= 2; @@ -485,6 +512,7 @@ export class QoderianView extends ItemView { private updateNewTabButtonVisibility(): void { if (!this.newTabButtonEl || !this.tabManager) return; + if (this.sessionTabsRedesignEnabled()) return; const canCreateTab = this.tabManager.canCreateTab(); this.newTabButtonEl.toggleClass('qoderian-hidden', !canCreateTab); @@ -546,7 +574,14 @@ export class QoderianView extends ItemView { } private async openHistoryConversation(conversationId: string): Promise { - await this.tabManager?.openConversation(conversationId); + // With the redesigned tabs, resuming a conversation must not replace the + // session in the active tab; legacy keeps opening it in the current tab. + const opened = await this.tabManager?.openConversation(conversationId, { + preferNewTab: this.sessionTabsRedesignEnabled(), + }); + if (opened === false) { + this.noticeSessionLimit(); + } this.historyDropdown?.removeClass('visible'); } @@ -554,13 +589,21 @@ export class QoderianView extends ItemView { conversationId: string, activate = true, ): Promise { - await this.tabManager?.openConversation(conversationId, { + const opened = await this.tabManager?.openConversation(conversationId, { preferNewTab: true, activate, }); + if (opened === false) { + this.noticeSessionLimit(); + } this.historyDropdown?.removeClass('visible'); } + private noticeSessionLimit(): void { + const maxTabs = this.plugin.settings.maxTabs ?? DEFAULT_MAX_TABS; + new Notice(t('chat.tabs.maxTabsReached', { count: String(maxTabs) })); + } + private getHistoryConversationStatus(conversationId: string): HistoryConversationStatus { const activeTab = this.tabManager?.getActiveTab(); if (activeTab?.conversationId === conversationId) { diff --git a/src/features/chat/tabs/tab-bar.ts b/src/features/chat/tabs/tab-bar.ts index 40a2c55..98d2d07 100644 --- a/src/features/chat/tabs/tab-bar.ts +++ b/src/features/chat/tabs/tab-bar.ts @@ -1,7 +1,11 @@ +import { setIcon } from 'obsidian'; + +import { t } from '../../../i18n/i18n'; import { scheduleAnimationFrame } from '../../../shared/dom/animation-frame'; import { setButtonTooltip } from '../../../shared/dom/tooltip'; import type { TabBarItem, TabId } from './types'; +const EDGE_PADDING = 8; const EXPANDED_TITLE_MAX_LENGTH = 32; const TRUNCATED_TITLE_SUFFIX = '...'; @@ -17,27 +21,37 @@ export interface TabBarCallbacks { onNewTab: () => void; } +export interface TabBarOptions { + /** Legacy numbered badges; false renders the session pills. */ + isLegacyMode?: () => boolean; +} + /** - * TabBar renders minimal numbered badge navigation. + * TabBar renders the session tabs of the active tab's composer row: + * numbered badges (legacy) or titled pills with a close affordance. */ export class TabBar { private containerEl: HTMLElement; private callbacks: TabBarCallbacks; - private expandedTitleTabIds = new Set(); + private isLegacyMode: () => boolean; private lastKnownScrollLeft = 0; + private lastActiveTabId: TabId | null = null; + private expandedTitleTabIds = new Set(); private readonly handleScroll = (): void => { this.captureScrollPosition(); }; - constructor(containerEl: HTMLElement, callbacks: TabBarCallbacks) { + constructor(containerEl: HTMLElement, callbacks: TabBarCallbacks, options: TabBarOptions = {}) { this.containerEl = containerEl; this.callbacks = callbacks; + this.isLegacyMode = options.isLegacyMode ?? (() => false); this.build(); } /** Builds the tab bar UI. */ private build(): void { this.containerEl.addClass('qoderian-tab-badges'); + this.syncLegacyClass(); this.containerEl.addEventListener('scroll', this.handleScroll); } @@ -46,22 +60,42 @@ export class TabBar { * @param items Tab items to render. */ update(items: TabBarItem[]): void { + const legacy = this.isLegacyMode(); this.captureStableScrollPosition(); - this.pruneExpandedTitleState(items); + this.syncLegacyClass(); + if (legacy) { + this.pruneExpandedTitleState(items); + } // Clear existing badges this.containerEl.empty(); // Render badges for (const item of items) { - this.renderBadge(item); + if (legacy) { + this.renderLegacyBadge(item); + } else { + this.renderSessionPill(item); + } } this.restoreScrollPosition(); + + if (legacy) { + return; + } + + // Only follow the active pill when the active tab actually changes, so a + // user scrolling through the strip is not yanked back by unrelated updates. + const activeId = items.find(item => item.isActive)?.id ?? null; + if (activeId !== this.lastActiveTabId) { + this.revealActiveBadge(items); + this.lastActiveTabId = activeId; + } } - /** Renders a single tab badge. */ - private renderBadge(item: TabBarItem): void { + /** Creates the badge shell both renderings share: state class, tooltip, click. */ + private createBadgeEl(item: TabBarItem, variantClass: string): HTMLElement { // Determine state class (priority: active > attention > streaming > idle) let stateClass = 'qoderian-tab-badge-idle'; if (item.isActive) { @@ -72,19 +106,12 @@ export class TabBar { stateClass = 'qoderian-tab-badge-streaming'; } - const isTitleExpanded = this.expandedTitleTabIds.has(item.id); const badgeEl = this.containerEl.createDiv({ - cls: [ - 'qoderian-tab-badge', - stateClass, - isTitleExpanded ? 'qoderian-tab-badge-expanded' : '', - ].filter(Boolean).join(' '), - text: this.getBadgeLabel(item), + cls: ['qoderian-tab-badge', stateClass, variantClass].filter(Boolean).join(' '), }); // Obsidian uses aria-label for hover tooltips here; adding title causes duplicate tooltip text. setButtonTooltip(badgeEl, item.title); - badgeEl.setAttribute('data-title-expanded', isTitleExpanded ? 'true' : 'false'); // Click handler to switch tab badgeEl.addEventListener('click', () => { @@ -92,28 +119,118 @@ export class TabBar { this.callbacks.onTabClick(item.id); }); + return badgeEl; + } + + /** Legacy rendering: numbered badge, double-click to expand, right-click to close. */ + private renderLegacyBadge(item: TabBarItem): void { + const isTitleExpanded = this.expandedTitleTabIds.has(item.id); + const badgeEl = this.createBadgeEl( + item, + isTitleExpanded ? 'qoderian-tab-badge-expanded' : '', + ); + const labelEl = badgeEl.createSpan({ + cls: 'qoderian-tab-badge-label', + text: this.getLegacyBadgeLabel(item, isTitleExpanded), + }); + badgeEl.setAttribute('data-title-expanded', isTitleExpanded ? 'true' : 'false'); + badgeEl.addEventListener('dblclick', (e) => { e.preventDefault(); e.stopPropagation(); - this.toggleBadgeTitle(item, badgeEl); + this.toggleBadgeTitle(item, badgeEl, labelEl); }); - // Right-click to close (if allowed) - if (item.canClose) { - badgeEl.addEventListener('contextmenu', (e) => { - e.preventDefault(); - this.callbacks.onTabClose(item.id); - }); + this.wireRightClickClose(item, badgeEl); + } + + /** Redesigned rendering: conversation title with a hover close button. */ + private renderSessionPill(item: TabBarItem): void { + const badgeEl = this.createBadgeEl( + item, + item.canClose ? 'qoderian-tab-badge-closable' : '', + ); + badgeEl.createSpan({ cls: 'qoderian-tab-badge-label', text: item.title }); + + this.renderCloseAffordance(item, badgeEl); + this.wireRightClickClose(item, badgeEl); + } + + /** Right-click closes the tab when it can be closed. */ + private wireRightClickClose(item: TabBarItem, badgeEl: HTMLElement): void { + if (!item.canClose) { + return; + } + + badgeEl.addEventListener('contextmenu', (e) => { + e.preventDefault(); + this.callbacks.onTabClose(item.id); + }); + } + + /** Adds the hover close button of the session pills. */ + private renderCloseAffordance(item: TabBarItem, badgeEl: HTMLElement): void { + if (!item.canClose) { + return; + } + + const closeEl = badgeEl.createSpan({ cls: 'qoderian-tab-badge-close' }); + closeEl.setAttribute('role', 'button'); + closeEl.setAttribute('tabindex', '0'); + setIcon(closeEl, 'x'); + setButtonTooltip(closeEl, t('nav.closeSession')); + + const closeTab = (event: Event): void => { + event.preventDefault(); + event.stopPropagation(); + this.callbacks.onTabClose(item.id); + }; + closeEl.addEventListener('click', closeTab); + closeEl.addEventListener('dblclick', (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + closeEl.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + closeTab(event); + } + }); + } + + /** Scrolls the active pill into the strip's viewport. */ + private revealActiveBadge(items: TabBarItem[]): void { + const index = items.findIndex(item => item.isActive); + const badge = index >= 0 ? this.containerEl.children[index] as HTMLElement | undefined : undefined; + if (!badge || typeof badge.getBoundingClientRect !== 'function') { + return; + } + + const viewport = this.containerEl.getBoundingClientRect(); + const rect = badge.getBoundingClientRect(); + // Unmeasured (hidden view) or already visible: nothing to do. + if (!viewport.width || !rect.width) { + return; } + if (rect.left >= viewport.left && rect.right <= viewport.right) { + return; + } + + const delta = rect.left < viewport.left + ? rect.left - viewport.left - EDGE_PADDING + : rect.right - viewport.right + EDGE_PADDING; + this.containerEl.scrollLeft = Math.max(0, this.containerEl.scrollLeft + delta); + this.captureScrollPosition(); } /** Destroys the tab bar. */ destroy(): void { this.containerEl.empty(); this.containerEl.removeClass('qoderian-tab-badges'); + this.containerEl.removeClass('qoderian-tab-badges--legacy'); this.containerEl.removeEventListener('scroll', this.handleScroll); this.expandedTitleTabIds.clear(); this.lastKnownScrollLeft = 0; + this.lastActiveTabId = null; } captureScrollPosition(): void { @@ -126,6 +243,8 @@ export class TabBar { if (scrollLeft <= 0) return; scheduleAnimationFrame(() => { + // A newer position (e.g. revealing the active pill) took over meanwhile. + if (this.lastKnownScrollLeft !== scrollLeft) return; if (this.containerEl.scrollLeft !== 0) return; this.containerEl.scrollLeft = scrollLeft; }, this.containerEl.ownerDocument.defaultView ?? null); @@ -138,6 +257,10 @@ export class TabBar { } } + private syncLegacyClass(): void { + this.containerEl.toggleClass('qoderian-tab-badges--legacy', this.isLegacyMode()); + } + private pruneExpandedTitleState(items: TabBarItem[]): void { const visibleTabIds = new Set(items.map(item => item.id)); for (const tabId of this.expandedTitleTabIds) { @@ -147,7 +270,7 @@ export class TabBar { } } - private toggleBadgeTitle(item: TabBarItem, badgeEl: HTMLElement): void { + private toggleBadgeTitle(item: TabBarItem, badgeEl: HTMLElement, labelEl: HTMLElement): void { if (this.expandedTitleTabIds.has(item.id)) { this.expandedTitleTabIds.delete(item.id); } else { @@ -155,13 +278,13 @@ export class TabBar { } const isTitleExpanded = this.expandedTitleTabIds.has(item.id); - badgeEl.textContent = this.getBadgeLabel(item); + labelEl.textContent = this.getLegacyBadgeLabel(item, isTitleExpanded); badgeEl.toggleClass('qoderian-tab-badge-expanded', isTitleExpanded); badgeEl.setAttribute('data-title-expanded', isTitleExpanded ? 'true' : 'false'); } - private getBadgeLabel(item: TabBarItem): string { - if (!this.expandedTitleTabIds.has(item.id)) { + private getLegacyBadgeLabel(item: TabBarItem, isTitleExpanded: boolean): string { + if (!isTitleExpanded) { return String(item.index); } diff --git a/src/features/chat/tabs/tab-manager.ts b/src/features/chat/tabs/tab-manager.ts index d8d20f7..3840c4e 100644 --- a/src/features/chat/tabs/tab-manager.ts +++ b/src/features/chat/tabs/tab-manager.ts @@ -66,6 +66,11 @@ export class TabManager implements TabManagerInterface { return Math.max(MIN_TABS, Math.min(MAX_TABS, settingsValue)); } + /** Whether the experimental session-tab interaction (own tab per session) is on. */ + private sessionTabsRedesignEnabled(): boolean { + return this.plugin.settings.enableSessionTabsRedesign === true; + } + constructor( plugin: QoderianPlugin, containerEl: HTMLElement, @@ -145,7 +150,7 @@ export class TabManager implements TabManagerInterface { this.plugin, this.view, (forkContext) => this.handleForkRequest(forkContext), - (conversationId) => this.openConversation(conversationId), + (conversationId) => this.openConversation(conversationId).then(() => undefined), () => this.getQoderCatalogConfig(tab), ); @@ -326,6 +331,7 @@ export class TabManager implements TabManagerInterface { /** Gets data for rendering the tab bar. */ getTabBarItems(): TabBarItem[] { + const legacy = !this.sessionTabsRedesignEnabled(); const items: TabBarItem[] = []; let index = 1; @@ -337,7 +343,10 @@ export class TabManager implements TabManagerInterface { isActive: tab.id === this.activeTabId, isStreaming: tab.state.isStreaming, needsAttention: tab.state.needsAttention, - canClose: this.tabs.size > 1 || !tab.state.isStreaming, + canClose: legacy + ? this.tabs.size > 1 || !tab.state.isStreaming + // A lone blank pill has nothing to close; anything else can be closed. + : this.tabs.size > 1 || (tab.conversationId !== null && !tab.state.isStreaming), }); } @@ -352,11 +361,12 @@ export class TabManager implements TabManagerInterface { * Opens a conversation in a new tab or existing tab. * @param conversationId The conversation to open. * @param options Controls tab creation behavior (backward-compatible with boolean). + * @returns False when a new tab was requested but the tab limit is reached. */ async openConversation( conversationId: string, options: boolean | OpenConversationOptions = false, - ): Promise { + ): Promise { const preferNewTab = typeof options === 'boolean' ? options : options.preferNewTab ?? false; @@ -368,7 +378,7 @@ export class TabManager implements TabManagerInterface { for (const tab of this.tabs.values()) { if (tab.conversationId === conversationId) { await this.switchToTab(tab.id); - return; + return true; } } @@ -380,26 +390,34 @@ export class TabManager implements TabManagerInterface { // Focus the other view and switch to its tab instead of opening duplicate await revealWorkspaceLeaf(this.plugin.app.workspace, crossViewResult.view.leaf); await crossViewResult.view.getTabManager()?.switchToTab(crossViewResult.tabId); - return; + return true; } - // Open in current tab or new tab - if (preferNewTab && this.canCreateTab()) { - await this.createTab(conversationId, undefined, { activate }); - } else { - // Open in current tab - // Note: Don't set tab.conversationId here - the onConversationIdChanged callback - // will sync it after successful switch. Setting it before switchTo() would cause - // incorrect tab metadata if switchTo() returns early (streaming/switching/creating). - const activeTab = this.getActiveTab(); - if (activeTab) { - await activeTab.controllers.conversationController?.switchTo(conversationId); + // Open in a new tab when requested. The redesigned tabs never replace the + // active tab silently; the legacy behavior keeps the old fallback. + if (preferNewTab) { + if (this.canCreateTab()) { + await this.createTab(conversationId, undefined, { activate }); + return true; } + if (this.sessionTabsRedesignEnabled()) { + return false; + } + } + + // Open in current tab + // Note: Don't set tab.conversationId here - the onConversationIdChanged callback + // will sync it after successful switch. Setting it before switchTo() would cause + // incorrect tab metadata if switchTo() returns early (streaming/switching/creating). + const activeTab = this.getActiveTab(); + if (activeTab) { + await activeTab.controllers.conversationController?.switchTo(conversationId); } + return true; } /** - * Creates a new conversation in the active tab. + * Creates a new conversation in the active tab (legacy tab interaction). */ async createNewConversation(): Promise { const activeTab = this.getActiveTab(); diff --git a/src/features/chat/tabs/types.ts b/src/features/chat/tabs/types.ts index 0e3b784..f48ee35 100644 --- a/src/features/chat/tabs/types.ts +++ b/src/features/chat/tabs/types.ts @@ -271,7 +271,7 @@ export interface TabManagerCallbacks { */ export interface TabBarItem { id: TabId; - /** 1-based index for display. */ + /** 1-based index, shown by the legacy numbered badges. */ index: number; title: string; isActive: boolean; diff --git a/src/features/settings/settings-tab.ts b/src/features/settings/settings-tab.ts index 84aa114..72a925c 100644 --- a/src/features/settings/settings-tab.ts +++ b/src/features/settings/settings-tab.ts @@ -1,6 +1,7 @@ import type { App, SettingDefinitionItem, SettingGroup } from 'obsidian'; import { Notice, PluginSettingTab, requireApiVersion, Setting } from 'obsidian'; +import { DEFAULT_QODERIAN_SETTINGS } from '../../app/settings/settings-storage'; import type { ChatViewPlacement } from '../../core/types/settings'; import { getAvailableLocales, getLocaleDisplayName, setLocale, t } from '../../i18n/i18n'; import type { Locale } from '../../i18n/types'; @@ -387,6 +388,15 @@ export class QoderianSettingTab extends PluginSettingTab { defaultValue: qoderSettings.enableMemory, }, }, + { + name: t('settings.enableSessionTabsRedesign.name'), + desc: t('settings.enableSessionTabsRedesign.desc'), + control: { + type: 'toggle', + key: 'enableSessionTabsRedesign', + defaultValue: DEFAULT_QODERIAN_SETTINGS.enableSessionTabsRedesign, + }, + }, ], }, ]; @@ -467,6 +477,10 @@ export class QoderianSettingTab extends PluginSettingTab { for (const view of this.plugin.getAllViews()) { view.refreshTabControls(); } + } else if (key === 'enableSessionTabsRedesign') { + for (const view of this.plugin.getAllViews()) { + view.refreshSessionTabsMode(); + } } else if (key === 'enableAutoTitleGeneration') { this.refreshDomState(); } else if (PROMPT_SETTING_KEYS.has(key)) { diff --git a/src/features/settings/ui/qoder-settings-tab.ts b/src/features/settings/ui/qoder-settings-tab.ts index 9cdbd3c..3526650 100644 --- a/src/features/settings/ui/qoder-settings-tab.ts +++ b/src/features/settings/ui/qoder-settings-tab.ts @@ -404,4 +404,19 @@ export function renderQoderSettingsTab(container: HTMLElement, context: QoderSet await context.plugin.saveSettings(); }) ); + + new Setting(container) + .setName(t('settings.enableSessionTabsRedesign.name')) + .setDesc(t('settings.enableSessionTabsRedesign.desc')) + .addToggle((toggle) => + toggle + .setValue(context.plugin.settings.enableSessionTabsRedesign === true) + .onChange(async (value) => { + context.plugin.settings.enableSessionTabsRedesign = value; + await context.plugin.saveSettings(); + for (const view of context.plugin.getAllViews()) { + view.refreshSessionTabsMode(); + } + }) + ); } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 5894084..3e102b8 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -62,8 +62,10 @@ }, "nav": { "newConversation": "Neue Unterhaltung", + "newSession": "Neue Sitzung", "chatHistory": "Chatverlauf", - "newChat": "Neuer Chat" + "newChat": "Neuer Chat", + "closeSession": "Sitzung schließen" }, "chat": { "rewind": { @@ -100,6 +102,9 @@ "commandNoMessages": "Verzweigung nicht möglich: keine Nachrichten in der Konversation", "commandNoAssistantUuid": "Verzweigung nicht möglich: keine Assistentenantwort mit Kennungen" }, + "tabs": { + "maxTabsReached": "Sitzungslimit erreicht ({count}). Du kannst das Limit in den Einstellungen ändern." + }, "bangBash": { "placeholder": "> Einen Bash-Befehl ausführen...", "commandPanel": "Befehlspanel", @@ -322,6 +327,10 @@ "name": "Gedächtnis", "desc": "Speichert wiederverwendbare Informationen automatisch und lädt sie über Unterhaltungen hinweg. Die Daten liegen im Gedächtnisverzeichnis der Qoder CLI und werden mit der Terminal-CLI geteilt." }, + "enableSessionTabsRedesign": { + "name": "Neue Sitzungs-Tabs", + "desc": "Sitzungs-Tabs zeigen den Titel mit Schließen-Schaltfläche, neue und aus dem Verlauf geöffnete Sitzungen laufen in eigenen Tabs, und die Leiste scrollt statt zu quetschen. Aus lässt die nummerierten Tabs unverändert." + }, "qoderSafeMode": { "name": "Berechtigungen im sicheren Modus", "desc": "Berechtigungsverhalten bei aktiviertem sicheren Modus.", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 085ad38..b3f1141 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -62,8 +62,10 @@ }, "nav": { "newConversation": "New conversation", + "newSession": "New session", "chatHistory": "Chat history", - "newChat": "New Chat" + "newChat": "New Chat", + "closeSession": "Close session" }, "chat": { "rewind": { @@ -100,6 +102,9 @@ "commandNoMessages": "Cannot fork: no messages in conversation", "commandNoAssistantUuid": "Cannot fork: no assistant response with identifiers" }, + "tabs": { + "maxTabsReached": "Session limit reached ({count}). You can change the limit in Settings." + }, "bangBash": { "placeholder": "> Run a bash command...", "commandPanel": "Command panel", @@ -322,6 +327,10 @@ "name": "Memory", "desc": "Automatically save and load reusable information across conversations. Memories live in your Qoder CLI memory directory and are shared with the terminal CLI." }, + "enableSessionTabsRedesign": { + "name": "New session tabs", + "desc": "Session tabs show the conversation title with a close button, new and resumed sessions open in their own tab, and the row scrolls instead of squeezing. Off keeps the numbered tabs." + }, "qoderSafeMode": { "name": "Safe mode permissions", "desc": "Permission behavior used while the Safe toggle is on.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3ba1fe0..bc284ca 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -62,8 +62,10 @@ }, "nav": { "newConversation": "Nueva conversación", + "newSession": "Nueva sesión", "chatHistory": "Historial del chat", - "newChat": "Nuevo chat" + "newChat": "Nuevo chat", + "closeSession": "Cerrar sesión" }, "chat": { "rewind": { @@ -100,6 +102,9 @@ "commandNoMessages": "No se puede bifurcar: no hay mensajes en la conversación", "commandNoAssistantUuid": "No se puede bifurcar: no hay respuesta del asistente con identificadores" }, + "tabs": { + "maxTabsReached": "Límite de sesiones alcanzado ({count}). Puedes cambiar el límite en Ajustes." + }, "bangBash": { "placeholder": "> Ejecuta un comando bash...", "commandPanel": "Panel de comandos", @@ -322,6 +327,10 @@ "name": "Memoria", "desc": "Guarda y carga automáticamente información reutilizable entre conversaciones. Los recuerdos se almacenan en el directorio de memoria de Qoder CLI y se comparten con la CLI de terminal." }, + "enableSessionTabsRedesign": { + "name": "Nuevas pestañas de sesión", + "desc": "Las pestañas muestran el título con un botón de cierre, las sesiones nuevas y las abiertas desde el historial usan su propia pestaña, y la fila se desplaza en lugar de comprimirse. Desactivado, se mantienen las pestañas numeradas." + }, "qoderSafeMode": { "name": "Permisos del modo seguro", "desc": "Comportamiento de permisos cuando el modo seguro está activado.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ebec1a1..af734a9 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -62,8 +62,10 @@ }, "nav": { "newConversation": "Nouvelle conversation", + "newSession": "Nouvelle session", "chatHistory": "Historique du chat", - "newChat": "Nouveau chat" + "newChat": "Nouveau chat", + "closeSession": "Fermer la session" }, "chat": { "rewind": { @@ -100,6 +102,9 @@ "commandNoMessages": "Impossible de bifurquer : aucun message dans la conversation", "commandNoAssistantUuid": "Impossible de bifurquer : aucune réponse de l’assistant avec des identifiants" }, + "tabs": { + "maxTabsReached": "Limite de sessions atteinte ({count}). Vous pouvez modifier la limite dans les paramètres." + }, "bangBash": { "placeholder": "> Exécuter une commande bash...", "commandPanel": "Panneau de commandes", @@ -322,6 +327,10 @@ "name": "Mémoire", "desc": "Enregistre et charge automatiquement les informations réutilisables entre les conversations. Les mémoires sont stockées dans le répertoire de mémoire de Qoder CLI et partagées avec la CLI du terminal." }, + "enableSessionTabsRedesign": { + "name": "Nouveaux onglets de session", + "desc": "Les onglets de session affichent le titre avec un bouton de fermeture, les nouvelles sessions et celles ouvertes depuis l'historique s'ouvrent dans leur propre onglet, et la rangée défile au lieu de se compresser. Désactivé, les onglets numérotés restent inchangés." + }, "qoderSafeMode": { "name": "Autorisations du mode sécurisé", "desc": "Comportement des autorisations lorsque le mode sécurisé est activé.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 28bb768..d5320e0 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -62,8 +62,10 @@ }, "nav": { "newConversation": "新しい会話", + "newSession": "新しいセッション", "chatHistory": "チャット履歴", - "newChat": "新しいチャット" + "newChat": "新しいチャット", + "closeSession": "セッションを閉じる" }, "chat": { "rewind": { @@ -100,6 +102,9 @@ "commandNoMessages": "フォークできません: 会話にメッセージがありません", "commandNoAssistantUuid": "フォークできません: 識別子付きのアシスタント応答がありません" }, + "tabs": { + "maxTabsReached": "セッションの上限({count})に達しました。設定で上限を変更できます。" + }, "bangBash": { "placeholder": "> bash コマンドを実行...", "commandPanel": "コマンドパネル", @@ -322,6 +327,10 @@ "name": "メモリ", "desc": "会話をまたいで再利用できる情報を自動的に保存・読み込みします。メモリは Qoder CLI のメモリディレクトリに保存され、ターミナル CLI と共有されます。" }, + "enableSessionTabsRedesign": { + "name": "新しいセッションタブ", + "desc": "セッションタブにタイトルと閉じるボタンを表示し、新規セッションと履歴から開いたセッションはそれぞれ別タブで開きます。タブが多いときは横スクロールします。オフなら従来の番号タブのままです。" + }, "qoderSafeMode": { "name": "セーフモードの権限", "desc": "セーフモードが有効なときに使用する権限動作です。", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index d78147c..da5b4ad 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -62,8 +62,10 @@ }, "nav": { "newConversation": "새 대화", + "newSession": "새 세션", "chatHistory": "대화 기록", - "newChat": "새 채팅" + "newChat": "새 채팅", + "closeSession": "세션 닫기" }, "chat": { "rewind": { @@ -100,6 +102,9 @@ "commandNoMessages": "포크할 수 없습니다: 대화에 메시지가 없습니다", "commandNoAssistantUuid": "포크할 수 없습니다: 식별자가 있는 어시스턴트 응답이 없습니다" }, + "tabs": { + "maxTabsReached": "세션 한도({count})에 도달했습니다. 설정에서 한도를 변경할 수 있습니다." + }, "bangBash": { "placeholder": "> bash 명령 실행...", "commandPanel": "명령 패널", @@ -322,6 +327,10 @@ "name": "메모리", "desc": "대화를 넘어 재사용할 수 있는 정보를 자동으로 저장하고 불러옵니다. 메모리는 Qoder CLI 메모리 디렉터리에 저장되며 터미널 CLI와 공유됩니다." }, + "enableSessionTabsRedesign": { + "name": "새 세션 탭", + "desc": "세션 탭에 제목과 닫기 버튼을 표시하고, 새 세션과 기록에서 연 세션을 각각 별도 탭에서 엽니다. 탭이 많으면 가로로 스크롤합니다. 끄면 기존 번호 탭이 유지됩니다." + }, "qoderSafeMode": { "name": "안전 모드 권한", "desc": "안전 모드가 켜져 있을 때 사용할 권한 동작입니다.", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3ed4671..63a2273 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -62,8 +62,10 @@ }, "nav": { "newConversation": "Nova conversa", + "newSession": "Nova sessão", "chatHistory": "Histórico de conversas", - "newChat": "Novo chat" + "newChat": "Novo chat", + "closeSession": "Fechar sessão" }, "chat": { "rewind": { @@ -100,6 +102,9 @@ "commandNoMessages": "Não é possível bifurcar: não há mensagens na conversa", "commandNoAssistantUuid": "Não é possível bifurcar: não há resposta do assistente com identificadores" }, + "tabs": { + "maxTabsReached": "Limite de sessões atingido ({count}). Você pode alterar o limite nas configurações." + }, "bangBash": { "placeholder": "> Executar um comando bash...", "commandPanel": "Painel de comandos", @@ -322,6 +327,10 @@ "name": "Memória", "desc": "Salva e carrega automaticamente informações reutilizáveis entre conversas. As memórias ficam no diretório de memória do Qoder CLI e são compartilhadas com a CLI do terminal." }, + "enableSessionTabsRedesign": { + "name": "Novas abas de sessão", + "desc": "As abas mostram o título com um botão de fechar, novas sessões e as abertas pelo histórico usam a própria aba, e a linha rola em vez de se comprimir. Desativado, mantém as abas numeradas." + }, "qoderSafeMode": { "name": "Permissões do modo seguro", "desc": "Comportamento das permissões quando o modo seguro está ativo.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 3e0b101..0d3ede7 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -62,8 +62,10 @@ }, "nav": { "newConversation": "Новый разговор", + "newSession": "Новая сессия", "chatHistory": "История чата", - "newChat": "Новый чат" + "newChat": "Новый чат", + "closeSession": "Закрыть сессию" }, "chat": { "rewind": { @@ -100,6 +102,9 @@ "commandNoMessages": "Нельзя форкнуть: в диалоге нет сообщений", "commandNoAssistantUuid": "Нельзя форкнуть: нет ответа ассистента с идентификаторами" }, + "tabs": { + "maxTabsReached": "Достигнут предел сессий ({count}). Изменить предел можно в настройках." + }, "bangBash": { "placeholder": "> Выполнить команду bash...", "commandPanel": "Панель команд", @@ -322,6 +327,10 @@ "name": "Память", "desc": "Автоматически сохраняет полезную информацию между беседами и загружает её при следующих разговорах. Воспоминания хранятся в каталоге памяти Qoder CLI и используются совместно с терминальным CLI." }, + "enableSessionTabsRedesign": { + "name": "Новые вкладки сессий", + "desc": "Вкладки сессий показывают заголовок с кнопкой закрытия, новые сессии и сессии из истории открываются в отдельной вкладке, а строка прокручивается вместо сжатия. Выключено — остаются нумерованные вкладки." + }, "qoderSafeMode": { "name": "Разрешения безопасного режима", "desc": "Правила разрешений при включённом безопасном режиме.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 27e5e2a..3099e94 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -62,8 +62,10 @@ }, "nav": { "newConversation": "新建会话", + "newSession": "新会话", "chatHistory": "聊天历史", - "newChat": "新会话" + "newChat": "新会话", + "closeSession": "关闭会话" }, "chat": { "rewind": { @@ -100,6 +102,9 @@ "commandNoMessages": "无法分叉:对话中没有消息", "commandNoAssistantUuid": "无法分叉:没有带标识符的助手回复" }, + "tabs": { + "maxTabsReached": "已达会话上限({count}),可在设置中调整上限。" + }, "bangBash": { "placeholder": "> 运行命令...", "commandPanel": "命令面板", @@ -322,6 +327,10 @@ "name": "记忆", "desc": "自动保存并加载跨会话可复用的信息。记忆存放在 Qoder CLI 的记忆目录中,与终端 CLI 共用。" }, + "enableSessionTabsRedesign": { + "name": "新版会话标签页", + "desc": "会话标签显示标题并可单独关闭;新会话与从历史打开的会话都占用独立标签页;会话多时横向滚动而不是挤压。关闭则保持原有的数字标签页交互。" + }, "qoderSafeMode": { "name": "安全模式权限", "desc": "开启安全模式时使用的权限策略。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 9253df1..31574ab 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -62,8 +62,10 @@ }, "nav": { "newConversation": "新建對話", + "newSession": "新增對話", "chatHistory": "聊天歷史", - "newChat": "新對話" + "newChat": "新對話", + "closeSession": "關閉對話" }, "chat": { "rewind": { @@ -100,6 +102,9 @@ "commandNoMessages": "無法分叉:對話中沒有訊息", "commandNoAssistantUuid": "無法分叉:沒有帶識別碼的助手回覆" }, + "tabs": { + "maxTabsReached": "已達對話上限({count}),可在設定中調整上限。" + }, "bangBash": { "placeholder": "> 執行 bash 指令...", "commandPanel": "指令面板", @@ -322,6 +327,10 @@ "name": "記憶", "desc": "自動儲存並載入跨工作階段可重複使用的資訊。記憶存放在 Qoder CLI 的記憶目錄中,與終端 CLI 共用。" }, + "enableSessionTabsRedesign": { + "name": "新版對話分頁", + "desc": "對話分頁顯示標題並可個別關閉;新對話與從歷史開啟的對話都使用獨立分頁;分頁過多時橫向捲動而非擠壓。關閉後維持原本的數字分頁。" + }, "qoderSafeMode": { "name": "安全模式權限", "desc": "開啟安全模式時使用的權限策略。", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index e3180b2..0c89ffb 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -35,8 +35,10 @@ export type TranslationKey = | 'feedback.noticeSuccessCopied' | 'feedback.noticeFailed' - // Nav row buttons and tab badges + // Nav row buttons and session pills | 'nav.newConversation' + | 'nav.newSession' + | 'nav.closeSession' | 'nav.chatHistory' | 'nav.newChat' @@ -155,6 +157,7 @@ export type TranslationKey = | 'chat.fork.errorNoActiveTab' | 'chat.fork.commandNoMessages' | 'chat.fork.commandNoAssistantUuid' + | 'chat.tabs.maxTabsReached' // Send queue (multi-message queue above the composer) | 'chat.queue.title' @@ -309,6 +312,8 @@ export type TranslationKey = | 'settings.loadUserSettings.desc' | 'settings.enableMemory.name' | 'settings.enableMemory.desc' + | 'settings.enableSessionTabsRedesign.name' + | 'settings.enableSessionTabsRedesign.desc' | 'settings.qoderSafeMode.name' | 'settings.qoderSafeMode.desc' | 'settings.qoderSafeMode.modes.acceptEdits' diff --git a/src/main.ts b/src/main.ts index a2afd17..0d1e47e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -148,6 +148,10 @@ export default class QoderianPlugin extends Plugin { id: 'new-session', name: t('commands.newSession'), checkCallback: (checking: boolean) => { + // Only the legacy tabs reset the active tab; the redesigned tabs open + // every session in its own tab, so the command stays out of the way. + if (this.settings.enableSessionTabsRedesign) return false; + const view = this.getView(); if (!view) return false; diff --git a/src/style/components/input.css b/src/style/components/input.css index 33f759a..19e956f 100644 --- a/src/style/components/input.css +++ b/src/style/components/input.css @@ -163,6 +163,8 @@ body.qoderian-composer-resizing * { display: flex; align-items: center; justify-content: space-between; + /* Breathing room between the scrolled session strip and the action buttons. */ + gap: 12px; width: 100%; min-width: 0; } diff --git a/src/style/components/tabs.css b/src/style/components/tabs.css index ae3e3bb..be39657 100644 --- a/src/style/components/tabs.css +++ b/src/style/components/tabs.css @@ -29,31 +29,70 @@ display: flex; align-items: center; justify-content: center; - flex: 0 0 24px; - width: 24px; - max-width: 24px; - height: 24px; + gap: 4px; + /* Pills keep their readable width; the strip scrolls instead of squeezing them. */ + flex: 0 0 auto; + min-width: 56px; + max-width: 180px; + height: 30px; box-sizing: border-box; - padding: 0; - border-radius: 4px; - border: 2px solid var(--background-modifier-border); + padding: 0 5px 0 10px; + border-radius: 10px; + border: 1px solid var(--background-modifier-border); font-size: 12px; font-weight: 500; cursor: pointer; color: var(--text-muted); - background: var(--background-primary); + background: transparent; overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; } -.qoderian-tab-badge-expanded { - flex: 0 0 auto; - justify-content: flex-start; - width: auto; - max-width: none; - padding: 0 8px; +.qoderian-tab-badge-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + white-space: nowrap; +} + +.qoderian-tab-badge-close { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 16px; + width: 16px; + height: 16px; + border-radius: 4px; + opacity: 0; + pointer-events: none; + color: var(--text-muted); + transition: opacity 0.12s ease, color 0.12s ease, background 0.12s ease; +} + +.qoderian-tab-badge-close svg { + width: 13px; + height: 13px; +} + +.qoderian-tab-badge-closable:hover > .qoderian-tab-badge-close, +.qoderian-tab-badge-closable:focus-within > .qoderian-tab-badge-close { + opacity: 1; + pointer-events: auto; +} + +.qoderian-tab-badge-close:hover, +.qoderian-tab-badge-close:focus-visible { + color: var(--text-normal); + background: var(--background-modifier-hover); + outline: none; +} + +/* Without a close affordance the pill keeps symmetric padding. */ +.qoderian-tab-badge:not(.qoderian-tab-badge-closable) { + padding-inline: 10px; } .qoderian-tab-badge:hover { @@ -62,8 +101,9 @@ } .qoderian-tab-badge-active { - border-color: var(--interactive-accent); + border-color: var(--text-faint); color: var(--text-normal); + background: var(--background-modifier-hover); } .qoderian-tab-badge-streaming { @@ -78,6 +118,41 @@ border-color: var(--background-modifier-border); } +/* Legacy numbered badges, kept while the experimental session tabs are off */ +.qoderian-tab-badges--legacy .qoderian-tab-badge { + flex: 0 0 24px; + width: 24px; + /* The pill floor would otherwise win over width/max-width. */ + min-width: 24px; + max-width: 24px; + height: 24px; + padding: 0; + border-width: 2px; + border-radius: 4px; + background: var(--background-primary); +} + +.qoderian-tab-badges--legacy .qoderian-tab-badge-label { + text-align: center; +} + +.qoderian-tab-badges--legacy .qoderian-tab-badge-expanded { + flex: 0 0 auto; + justify-content: flex-start; + width: auto; + max-width: none; + padding: 0 8px; +} + +.qoderian-tab-badges--legacy .qoderian-tab-badge-expanded .qoderian-tab-badge-label { + text-align: start; +} + +.qoderian-tab-badges--legacy .qoderian-tab-badge-active { + border-color: var(--interactive-accent); + background: var(--background-primary); +} + .qoderian-tab-content-container { display: flex; flex-direction: column; diff --git a/tests/helpers/mock-element.ts b/tests/helpers/mock-element.ts index 4d6dc5d..56f6c9d 100644 --- a/tests/helpers/mock-element.ts +++ b/tests/helpers/mock-element.ts @@ -4,6 +4,7 @@ export interface MockElement { style: Record; dataset: Record; scrollTop: number; + scrollLeft: number; scrollHeight: number; innerHTML: string; textContent: string; @@ -191,6 +192,7 @@ export function createMockEl(tag = 'div'): any { style, dataset, scrollTop: 0, + scrollLeft: 0, scrollHeight: 0, innerHTML: '', diff --git a/tests/integration/main.test.ts b/tests/integration/main.test.ts index 7b936a7..0a0da85 100644 --- a/tests/integration/main.test.ts +++ b/tests/integration/main.test.ts @@ -591,7 +591,7 @@ describe('QoderianPlugin', () => { mockApp.workspace.getLeavesOfType.mockReturnValue([{ view: {} }]); - for (const commandId of ['new-tab', 'new-session', 'close-current-tab']) { + for (const commandId of ['new-tab', 'close-current-tab']) { const command = getRegisteredCommand(commandId); expect(() => command.checkCallback(true)).not.toThrow(); diff --git a/tests/unit/features/chat/chat-view.test.ts b/tests/unit/features/chat/chat-view.test.ts index 0286885..93b0866 100644 --- a/tests/unit/features/chat/chat-view.test.ts +++ b/tests/unit/features/chat/chat-view.test.ts @@ -1,56 +1,138 @@ import { createMockEl } from '@test/helpers/mock-element'; -import { Platform, Scope } from 'obsidian'; +import { Notice, Platform, Scope } from 'obsidian'; import { QoderianView } from '@/features/chat/chat-view'; +import { setLocale, t } from '@/i18n/i18n'; const MockScope = Scope as typeof Scope & { instances: Scope[] }; +const MockNotice = Notice as unknown as jest.Mock; function createViewHarness(options: { - canCreateTab: boolean; - tabCount?: number; + createdTab?: unknown; + maxTabs?: number; }): { - newTabButtonEl: ReturnType; + createTab: jest.Mock; view: any; } { - const newTabButtonEl = createMockEl(); + const createTab = jest.fn().mockResolvedValue(options.createdTab ?? null); const view = Object.create(QoderianView.prototype) as any; view.plugin = { - settings: {}, + settings: { maxTabs: options.maxTabs ?? 4 }, }; - view.tabManager = { - canCreateTab: jest.fn().mockReturnValue(options.canCreateTab), - getTabCount: jest.fn().mockReturnValue(options.tabCount ?? 1), - }; - view.tabBarContainerEl = createMockEl(); - view.logoEl = createMockEl(); - view.newTabButtonEl = newTabButtonEl; + view.tabManager = { createTab }; - return { newTabButtonEl, view }; + return { createTab, view }; } describe('QoderianView tab controls', () => { - it('hides the new-tab button when the tab manager is at capacity', () => { - const { newTabButtonEl, view } = createViewHarness({ canCreateTab: false }); + beforeEach(() => { + MockNotice.mockClear(); + setLocale('en'); + }); + + it('notices the limit and points to settings when no tab can be created', async () => { + const { view } = createViewHarness({ createdTab: null, maxTabs: 4 }); + + await view.createNewTab(); + + expect(MockNotice).toHaveBeenCalledTimes(1); + expect(MockNotice.mock.calls[0][0]).toBe( + t('chat.tabs.maxTabsReached', { count: '4' }), + ); + }); + + it('stays quiet when a new tab is created', async () => { + const { createTab, view } = createViewHarness({ createdTab: { id: 'tab-2' } }); + + await view.createNewTab(); + + expect(createTab).toHaveBeenCalledTimes(1); + expect(MockNotice).not.toHaveBeenCalled(); + }); + + it('hides the strip and the new-session button at the limit while the redesign is off', () => { + const tabBarContainerEl = createMockEl(); + const newTabButtonEl = createMockEl(); + const view = Object.create(QoderianView.prototype) as any; + view.plugin = { settings: {} }; + view.tabManager = { + getTabCount: jest.fn().mockReturnValue(1), + canCreateTab: jest.fn().mockReturnValue(false), + }; + view.tabBarContainerEl = tabBarContainerEl; + view.newTabButtonEl = newTabButtonEl; view.refreshTabControls(); + expect(tabBarContainerEl.hasClass('qoderian-hidden')).toBe(true); expect(newTabButtonEl.hasClass('qoderian-hidden')).toBe(true); - expect(newTabButtonEl.getAttribute('aria-disabled')).toBe('true'); expect(newTabButtonEl.getAttribute('aria-hidden')).toBe('true'); }); - it('shows the new-tab button when another tab can be created', () => { - const { newTabButtonEl, view } = createViewHarness({ canCreateTab: true }); - newTabButtonEl.addClass('qoderian-hidden'); - newTabButtonEl.setAttribute('aria-disabled', 'true'); - newTabButtonEl.setAttribute('aria-hidden', 'true'); + it('keeps a single session visible and the new-session button enabled while the redesign is on', () => { + const tabBarContainerEl = createMockEl(); + const newTabButtonEl = createMockEl(); + const view = Object.create(QoderianView.prototype) as any; + view.plugin = { settings: { enableSessionTabsRedesign: true } }; + view.tabManager = { + getTabCount: jest.fn().mockReturnValue(1), + canCreateTab: jest.fn().mockReturnValue(false), + }; + view.tabBarContainerEl = tabBarContainerEl; + view.newTabButtonEl = newTabButtonEl; view.refreshTabControls(); + expect(tabBarContainerEl.hasClass('qoderian-hidden')).toBe(false); expect(newTabButtonEl.hasClass('qoderian-hidden')).toBe(false); expect(newTabButtonEl.getAttribute('aria-disabled')).toBeNull(); - expect(newTabButtonEl.getAttribute('aria-hidden')).toBeNull(); + }); + + it('opens a history conversation in a new tab when the redesign is on', async () => { + const openConversation = jest.fn().mockResolvedValue(true); + const view = Object.create(QoderianView.prototype) as any; + view.plugin = { settings: { enableSessionTabsRedesign: true } }; + view.tabManager = { openConversation }; + view.historyDropdown = createMockEl(); + view.historyDropdown.addClass('visible'); + + await view.openHistoryConversation('conv-9'); + + expect(openConversation).toHaveBeenCalledWith('conv-9', { preferNewTab: true }); + expect(view.historyDropdown.hasClass('visible')).toBe(false); + }); + + it('opens a history conversation in the active tab while the redesign is off', async () => { + const openConversation = jest.fn().mockResolvedValue(true); + const view = Object.create(QoderianView.prototype) as any; + view.plugin = { settings: {} }; + view.tabManager = { openConversation }; + view.historyDropdown = createMockEl(); + view.historyDropdown.addClass('visible'); + + await view.openHistoryConversation('conv-9'); + + expect(openConversation).toHaveBeenCalledWith('conv-9', { preferNewTab: false }); + expect(MockNotice).not.toHaveBeenCalled(); + expect(view.historyDropdown.hasClass('visible')).toBe(false); + }); + + it('notices the limit when a history conversation cannot open a new tab', async () => { + const openConversation = jest.fn().mockResolvedValue(false); + const view = Object.create(QoderianView.prototype) as any; + view.plugin = { settings: { maxTabs: 3, enableSessionTabsRedesign: true } }; + view.tabManager = { openConversation }; + view.historyDropdown = createMockEl(); + view.historyDropdown.addClass('visible'); + + await view.openHistoryConversation('conv-9'); + + expect(MockNotice).toHaveBeenCalledTimes(1); + expect(MockNotice.mock.calls[0][0]).toBe( + t('chat.tabs.maxTabsReached', { count: '3' }), + ); + expect(view.historyDropdown.hasClass('visible')).toBe(false); }); it('keeps tab controls in the view-owned input row', () => { diff --git a/tests/unit/features/chat/tabs/tab-bar.test.ts b/tests/unit/features/chat/tabs/tab-bar.test.ts new file mode 100644 index 0000000..778e7fe --- /dev/null +++ b/tests/unit/features/chat/tabs/tab-bar.test.ts @@ -0,0 +1,150 @@ +import { createMockEl } from '@test/helpers/mock-element'; + +import { TabBar, type TabBarCallbacks } from '@/features/chat/tabs/tab-bar'; +import type { TabBarItem } from '@/features/chat/tabs/types'; + +function makeItem(overrides: Partial = {}): TabBarItem { + return { + id: 'tab-1', + index: 1, + title: 'Refactor session loading', + isActive: true, + isStreaming: false, + needsAttention: false, + canClose: true, + ...overrides, + }; +} + +function makeRect(left: number, right: number): DOMRect { + return { + left, right, width: right - left, + top: 0, bottom: 30, height: 30, x: left, y: 0, + toJSON: () => ({}), + } as DOMRect; +} + +/** Gives the strip a viewport and every rendered pill a horizontal span. */ +function installRects( + containerEl: any, + viewport: [number, number], + pills: Array<[number, number]>, +): void { + containerEl.getBoundingClientRect = () => makeRect(viewport[0], viewport[1]); + const originalCreateDiv = containerEl.createDiv; + let index = 0; + containerEl.createDiv = (opts?: { cls?: string; text?: string }) => { + const el = originalCreateDiv.call(containerEl, opts); + const span = pills[index++] ?? [0, 0]; + el.getBoundingClientRect = () => makeRect(span[0], span[1]); + return el; + }; +} + +function createBar(options: { legacy?: boolean } = {}): { + bar: TabBar; + containerEl: ReturnType; + callbacks: { [K in keyof TabBarCallbacks]: jest.Mock }; +} { + const containerEl = createMockEl(); + const callbacks = { + onTabClick: jest.fn(), + onTabClose: jest.fn(), + onNewTab: jest.fn(), + }; + const bar = new TabBar(containerEl as unknown as HTMLElement, callbacks, { + isLegacyMode: () => options.legacy === true, + }); + return { bar, containerEl, callbacks }; +} + +describe('TabBar session pills', () => { + it('labels each pill with the session title', () => { + const { bar, containerEl } = createBar(); + + bar.update([ + makeItem({ id: 'tab-1', title: 'Fix tab labels' }), + makeItem({ id: 'tab-2', title: 'Ship release', isActive: false }), + ]); + + const labels = containerEl.querySelectorAll('.qoderian-tab-badge-label') as Array<{ textContent: string }>; + expect(labels.map(label => label.textContent)).toEqual(['Fix tab labels', 'Ship release']); + }); + + it('renders no close affordance for a lone blank session', () => { + const { bar, containerEl } = createBar(); + + bar.update([makeItem({ canClose: false })]); + + expect(containerEl.querySelector('.qoderian-tab-badge-close')).toBeNull(); + expect(containerEl.children[0].hasClass('qoderian-tab-badge-closable')).toBe(false); + }); + + it('closes the session from the close affordance without switching', () => { + const { bar, containerEl, callbacks } = createBar(); + bar.update([makeItem({ id: 'tab-2' })]); + + const closeEl = containerEl.querySelector('.qoderian-tab-badge-close'); + closeEl?.dispatchEvent({ + type: 'click', + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }); + + expect(callbacks.onTabClose).toHaveBeenCalledWith('tab-2'); + expect(callbacks.onTabClick).not.toHaveBeenCalled(); + }); + + it('switches to the session when the pill is clicked', () => { + const { bar, containerEl, callbacks } = createBar(); + bar.update([makeItem({ id: 'tab-3' })]); + + containerEl.children[0].dispatchEvent({ type: 'click' }); + + expect(callbacks.onTabClick).toHaveBeenCalledWith('tab-3'); + }); + + it('scrolls the active pill into view when it sits past the right edge', () => { + const { bar, containerEl } = createBar(); + installRects(containerEl, [0, 200], [[0, 120], [260, 380]]); + + bar.update([ + makeItem({ id: 'tab-1', title: 'First', isActive: false }), + makeItem({ id: 'tab-2', title: 'Second', isActive: true }), + ]); + + expect(containerEl.scrollLeft).toBe(380 - 200 + 8); + }); + + it('leaves the scroll position alone when the active pill is already visible', () => { + const { bar, containerEl } = createBar(); + installRects(containerEl, [0, 400], [[0, 120], [130, 250]]); + + bar.update([ + makeItem({ id: 'tab-1', title: 'First', isActive: false }), + makeItem({ id: 'tab-2', title: 'Second', isActive: true }), + ]); + + expect(containerEl.scrollLeft).toBe(0); + }); + + it('renders numbered badges without a close affordance in legacy mode', () => { + const { bar, containerEl } = createBar({ legacy: true }); + + bar.update([makeItem({ id: 'tab-1', index: 2, title: 'Fix tab labels' })]); + + expect(containerEl.hasClass('qoderian-tab-badges--legacy')).toBe(true); + expect(containerEl.querySelector('.qoderian-tab-badge-label')?.textContent).toBe('2'); + expect(containerEl.querySelector('.qoderian-tab-badge-close')).toBeNull(); + }); + + it('expands the title on double click in legacy mode', () => { + const { bar, containerEl } = createBar({ legacy: true }); + bar.update([makeItem({ id: 'tab-1', index: 1, title: 'Fix tab labels' })]); + + containerEl.children[0].dispatchEvent({ type: 'dblclick', preventDefault: jest.fn(), stopPropagation: jest.fn() }); + + expect(containerEl.querySelector('.qoderian-tab-badge-label')?.textContent).toBe('Fix tab labels'); + expect(containerEl.children[0].hasClass('qoderian-tab-badge-expanded')).toBe(true); + }); +}); diff --git a/tests/unit/features/chat/tabs/tab-manager-open-conversation.test.ts b/tests/unit/features/chat/tabs/tab-manager-open-conversation.test.ts new file mode 100644 index 0000000..9a94fab --- /dev/null +++ b/tests/unit/features/chat/tabs/tab-manager-open-conversation.test.ts @@ -0,0 +1,98 @@ +import { createMockEl } from '@test/helpers/mock-element'; + +import { TabManager } from '@/features/chat/tabs/tab-manager'; + +function makeManager(options: { + maxTabs: number; + tabs: Array<{ id: string; conversationId: string | null }>; + redesign?: boolean; +}): { + manager: TabManager; + switchTo: jest.Mock; +} { + const switchTo = jest.fn().mockResolvedValue(undefined); + const plugin = { + settings: { + maxTabs: options.maxTabs, + enableSessionTabsRedesign: options.redesign ?? true, + }, + app: { workspace: {} }, + findConversationAcrossViews: jest.fn().mockReturnValue(null), + } as any; + const manager = new TabManager(plugin, createMockEl() as unknown as HTMLElement, {} as any, {}); + + const tabs = new Map(options.tabs.map(tab => [tab.id, { + id: tab.id, + conversationId: tab.conversationId, + state: {}, + controllers: { conversationController: { switchTo } }, + }])); + (manager as any).tabs = tabs; + (manager as any).activeTabId = options.tabs[0]?.id ?? null; + + return { manager, switchTo }; +} + +describe('TabManager.openConversation', () => { + it('refuses a new tab at the limit instead of replacing the active tab', async () => { + const { manager, switchTo } = makeManager({ + maxTabs: 3, + tabs: [ + { id: 'tab-1', conversationId: 'conv-1' }, + { id: 'tab-2', conversationId: null }, + { id: 'tab-3', conversationId: null }, + ], + }); + + const opened = await manager.openConversation('conv-history', { preferNewTab: true }); + + expect(opened).toBe(false); + expect(switchTo).not.toHaveBeenCalled(); + }); + + it('switches to the existing tab when the conversation is already open', async () => { + const { manager } = makeManager({ + maxTabs: 3, + tabs: [ + { id: 'tab-1', conversationId: 'conv-1' }, + { id: 'tab-2', conversationId: null }, + { id: 'tab-3', conversationId: null }, + ], + }); + const switchTab = jest.spyOn(manager, 'switchToTab').mockResolvedValue(undefined); + + const opened = await manager.openConversation('conv-1', { preferNewTab: true }); + + expect(opened).toBe(true); + expect(switchTab).toHaveBeenCalledWith('tab-1'); + }); + + it('still opens in the active tab when a new tab is not requested', async () => { + const { manager, switchTo } = makeManager({ + maxTabs: 3, + tabs: [{ id: 'tab-1', conversationId: 'conv-1' }], + }); + + const opened = await manager.openConversation('conv-2'); + + expect(opened).toBe(true); + expect(switchTo).toHaveBeenCalledWith('conv-2'); + }); + + it('falls back to the active tab at the limit while the redesign is off', async () => { + const { manager, switchTo } = makeManager({ + maxTabs: 3, + redesign: false, + tabs: [ + { id: 'tab-1', conversationId: 'conv-1' }, + { id: 'tab-2', conversationId: null }, + { id: 'tab-3', conversationId: null }, + ], + }); + + const opened = await manager.openConversation('conv-history', { preferNewTab: true }); + + expect(opened).toBe(true); + expect(switchTo).toHaveBeenCalledWith('conv-history'); + }); +}); diff --git a/tests/unit/features/settings/settings-tab.test.ts b/tests/unit/features/settings/settings-tab.test.ts index faebdb6..2521ee3 100644 --- a/tests/unit/features/settings/settings-tab.test.ts +++ b/tests/unit/features/settings/settings-tab.test.ts @@ -3,7 +3,6 @@ import { QoderianSettingTab } from '@/features/settings/settings-tab'; function createTab() { const view = { refreshLocalizedChrome: jest.fn(), - refreshTabControls: jest.fn(), }; const plugin = { settings: { diff --git a/tests/unit/i18n/locales.test.ts b/tests/unit/i18n/locales.test.ts index a1e639e..7eb64c4 100644 --- a/tests/unit/i18n/locales.test.ts +++ b/tests/unit/i18n/locales.test.ts @@ -112,6 +112,11 @@ const localizedKeys = [ 'feedback.noticeSuccessWithId', 'feedback.noticeSuccessCopied', 'feedback.noticeFailed', + 'nav.newSession', + 'nav.closeSession', + 'chat.tabs.maxTabsReached', + 'settings.enableSessionTabsRedesign.name', + 'settings.enableSessionTabsRedesign.desc', ] as const; const staleBangBashDesc =