From 6134a319ef5c3cf543bdbf8976e9e6ba3f0fe448 Mon Sep 17 00:00:00 2001 From: liuxuezhuo <44692579+luoxuanzao@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:46:49 +0800 Subject: [PATCH] feat: isolate session history per Qoder CLI edition Session metadata in .qoderian/sessions mixed conversations from both CLI editions, so switching editions left the other edition's sessions visible in the history list even though their message files live under a different config root (~/.qoder vs ~/.qoder-cn). - Stamp SessionMetadata/Conversation with the owning edition on create and save; updates keep the original stamp - Filter the conversation index by the active edition; legacy metadata without a stamp stays visible unless its jsonl provably lives under the other edition's projects root - One-shot startup migration attributes legacy metadata by history file location; sessions missing everywhere stay unstamped for re-evaluation - Switching editions force-closes every open tab before activating the new edition so no previous-edition conversation stays open (closing the last tab spawns a blank one), then rebuilds the conversation index - Add edition-aware session path/existence helpers and unit tests Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 6 +- src/core/types/chat.ts | 5 + .../settings/ui/qoder-settings-tab.ts | 19 ++- src/main.ts | 105 +++++++++++++--- src/qoder/history/sdk-session-paths.ts | 25 +++- src/qoder/history/session-edition-filter.ts | 66 ++++++++++ .../settings/ui/qoder-settings-tab.test.ts | 29 ++++- .../history/session-edition-filter.test.ts | 113 ++++++++++++++++++ 8 files changed, 342 insertions(+), 26 deletions(-) create mode 100644 src/qoder/history/session-edition-filter.ts create mode 100644 tests/unit/qoder/history/session-edition-filter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b182cfe..fb5c550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,11 @@ version with its date and start a fresh empty `[Unreleased]` above it. international build (`qodercli`, config under `~/.qoder`) or the China build (`qoderclicn`, config under `~/.qoder-cn`). Auto- detection, session history, global plugins, and login hints all - follow the selected edition. + follow the selected edition. Session history is isolated per + edition: each conversation is stamped with the edition that owns + it, switching editions force-closes all open tabs, and the history + list shows only the active edition's sessions (pre-existing + sessions are attributed by where their history files live). - Per-model context and thinking editor in the model selector, mirroring the Qoder IDE: hovering a model row reveals an edit affordance that opens a side editor card with context window diff --git a/src/core/types/chat.ts b/src/core/types/chat.ts index 1a2dc3d..e77d35d 100644 --- a/src/core/types/chat.ts +++ b/src/core/types/chat.ts @@ -1,4 +1,5 @@ import type { ToolUseResult } from './diff'; +import type { QoderCliEdition } from './settings'; import type { SubagentInfo, SubagentMode, ToolCallInfo } from './tools'; /** Fork origin reference: identifies the source session and checkpoint. */ @@ -97,6 +98,8 @@ export interface Conversation { enabledMcpServers?: string[]; /** Assistant checkpoint identifier for resumeAtMessageId after rewind. */ resumeAtMessageId?: string; + /** Qoder CLI edition whose config root stores this conversation's history. */ + edition?: QoderCliEdition; } /** Lightweight conversation metadata for the history dropdown. */ @@ -134,6 +137,8 @@ export interface SessionMetadata { usage?: UsageInfo; /** Assistant checkpoint identifier for resumeAtMessageId after rewind. */ resumeAtMessageId?: string; + /** Qoder CLI edition owning the session history (absent in legacy files). */ + edition?: QoderCliEdition; } /** diff --git a/src/features/settings/ui/qoder-settings-tab.ts b/src/features/settings/ui/qoder-settings-tab.ts index 4a25d3f..62268e4 100644 --- a/src/features/settings/ui/qoder-settings-tab.ts +++ b/src/features/settings/ui/qoder-settings-tab.ts @@ -163,7 +163,9 @@ export function renderQoderCliPathControl( /** * Attaches the CLI edition dropdown to `setting`. Switching editions changes * the executable name and the CLI's user config root, so the resolver cache - * is dropped and every tab restarts, mirroring a CLI path change. + * is dropped, every open tab is force-closed (users must not continue a + * conversation owned by the other edition), the conversation index is + * rebuilt for the new edition, and a blank tab takes over. */ export function renderQoderCliEditionControl( setting: Setting, @@ -179,14 +181,21 @@ export function renderQoderCliEditionControl( .setValue(getQoderSettings(settingsBag).edition) .onChange(async (value) => { const edition = normalizeQoderCliEdition(value); + // Close every tab (even streaming ones) before activating the new + // edition so no conversation from the previous edition stays open and + // in-flight saves still stamp the outgoing edition. Closing the last + // tab spawns a blank one. + const tabManager = context.plugin.getView()?.getTabManager(); + if (tabManager) { + for (const tab of [...tabManager.getAllTabs()]) { + await tabManager.closeTab(tab.id, true); + } + } updateQoderSettings(settingsBag, { edition }); await context.plugin.saveSettings(); qoderWorkspace.cliResolver.reset(); await qoderWorkspace.pluginManager.loadPlugins(); - const view = context.plugin.getView(); - await view?.getTabManager()?.broadcastToAllTabs( - (service) => Promise.resolve(service.cleanup()) - ); + await context.plugin.reloadConversationIndex(); void qoderWorkspace.agentCatalog.refresh(); }); }); diff --git a/src/main.ts b/src/main.ts index 170b1b2..0ebf1ba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ import type { Editor, WorkspaceLeaf } from 'obsidian'; import { addIcon, MarkdownView, Notice, Plugin } from 'obsidian'; import { QoderianStorage } from './app/storage/app-storage'; -import { beginRestoreReport } from './core/diagnostics/restore-report'; +import { beginRestoreReport, reportRestoreIssue } from './core/diagnostics/restore-report'; import { buildCursorContext } from './core/editor/editor-context'; import { getVaultPath } from './core/fs/path'; import type { @@ -25,9 +25,11 @@ import { type InlineEditContext, InlineEditModal } from './features/inline-edit/ import { QoderianSettingTab } from './features/settings/settings-tab'; import { setLocale, t } from './i18n/i18n'; import type { Locale } from './i18n/types'; -import { setActiveQoderCliEdition } from './qoder/config/cli-edition'; +import { getActiveQoderCliEdition, setActiveQoderCliEdition } from './qoder/config/cli-edition'; import { normalizeQoderSettings } from './qoder/config/qoder-settings-reconciler'; import { getQoderSettings } from './qoder/config/settings'; +import { sdkSessionExistsForEdition } from './qoder/history/sdk-session-paths'; +import { resolveLegacySessionEdition, selectMetadataForEdition } from './qoder/history/session-edition-filter'; import { extractUserDisplayContent } from './qoder/prompt/context/prompt-context'; import { createQoderServices, @@ -395,6 +397,7 @@ export default class QoderianPlugin extends Plugin { enabledMcpServers: conversation.enabledMcpServers, usage: conversation.usage, resumeAtMessageId: conversation.resumeAtMessageId, + edition: conversation.edition ?? getActiveQoderCliEdition(), }; } @@ -415,7 +418,45 @@ export default class QoderianPlugin extends Plugin { const didNormalizeModelVariants = this.normalizeModelVariantSettings(); const allMetadata = await this.storage.sessions.listMetadata(); - this.conversations = allMetadata.map(meta => { + await this.migrateLegacySessionEditions(allMetadata); + this.conversations = await this.buildConversationIndex(allMetadata); + setLocale(this.settings.locale as Locale); + + if (didNormalizeModelVariants) { + await this.saveSettings(); + } + } + + normalizeModelVariantSettings(): boolean { + return normalizeQoderSettings(this.settings); + } + + async saveSettings() { + this.syncActiveQoderCliEdition(); + await this.storage.saveQoderianSettings(this.settings); + } + + /** Keeps the edition-aware path helpers aligned with the persisted settings. */ + private syncActiveQoderCliEdition() { + setActiveQoderCliEdition(getQoderSettings(this.settings).edition); + } + + /** + * Builds the in-memory conversation index from session metadata, keeping + * only the sessions owned by the active CLI edition. + */ + private async buildConversationIndex(allMetadata: SessionMetadata[]): Promise { + const edition = getActiveQoderCliEdition(); + const otherEdition = edition === 'cn' ? 'global' : 'cn'; + const vaultPath = getVaultPath(this.app); + const visible = selectMetadataForEdition( + allMetadata, + edition, + (sessionId) => vaultPath !== null + && sdkSessionExistsForEdition(vaultPath, sessionId, otherEdition), + ); + + return visible.map(meta => { const resumeSessionId = meta.sessionId !== undefined ? meta.sessionId : meta.id; return { @@ -433,29 +474,58 @@ export default class QoderianPlugin extends Plugin { usage: meta.usage, titleGenerationStatus: meta.titleGenerationStatus, resumeAtMessageId: meta.resumeAtMessageId, + edition: meta.edition ?? edition, }; }).sort( (a, b) => (b.lastResponseAt ?? b.updatedAt) - (a.lastResponseAt ?? a.updatedAt) ); - setLocale(this.settings.locale as Locale); - - if (didNormalizeModelVariants) { - await this.saveSettings(); - } } - normalizeModelVariantSettings(): boolean { - return normalizeQoderSettings(this.settings); + /** Rebuilds the conversation index after the CLI edition changes. */ + async reloadConversationIndex(): Promise { + const allMetadata = await this.storage.sessions.listMetadata(); + this.conversations = await this.buildConversationIndex(allMetadata); } - async saveSettings() { - this.syncActiveQoderCliEdition(); - await this.storage.saveQoderianSettings(this.settings); - } + /** + * One-shot upgrade for metadata written before the `edition` field existed: + * stamps each legacy session with the edition whose config root holds its + * history file, so future loads no longer depend on filesystem probing. + * Sessions whose files are missing everywhere stay unstamped. + */ + private async migrateLegacySessionEditions(allMetadata: SessionMetadata[]): Promise { + const edition = getActiveQoderCliEdition(); + const otherEdition = edition === 'cn' ? 'global' : 'cn'; + const vaultPath = getVaultPath(this.app); + if (vaultPath === null) { + return; + } - /** Keeps the edition-aware path helpers aligned with the persisted settings. */ - private syncActiveQoderCliEdition() { - setActiveQoderCliEdition(getQoderSettings(this.settings).edition); + for (const meta of allMetadata) { + if (meta.edition !== undefined) { + continue; + } + const resolved = resolveLegacySessionEdition(meta, edition, (sessionId) => { + if (sdkSessionExistsForEdition(vaultPath, sessionId, edition)) { + return 'active'; + } + return sdkSessionExistsForEdition(vaultPath, sessionId, otherEdition) + ? 'other' + : 'unknown'; + }); + if (resolved === undefined) { + continue; + } + try { + await this.storage.sessions.saveMetadata({ ...meta, edition: resolved }); + meta.edition = resolved; + } catch { + reportRestoreIssue( + 'metadata', + `Failed to stamp edition on session metadata "${meta.id}"; it will be re-attempted on next load.`, + ); + } + } } getResolvedQoderCliPath(): string | null { @@ -511,6 +581,7 @@ export default class QoderianPlugin extends Plugin { updatedAt: Date.now(), sessionId: sessionId ?? null, messages: [], + edition: getActiveQoderCliEdition(), }; this.conversations.unshift(conversation); diff --git a/src/qoder/history/sdk-session-paths.ts b/src/qoder/history/sdk-session-paths.ts index 76e8456..1d4f641 100644 --- a/src/qoder/history/sdk-session-paths.ts +++ b/src/qoder/history/sdk-session-paths.ts @@ -2,6 +2,7 @@ import { existsSync } from 'fs'; import * as fs from 'fs/promises'; import * as path from 'path'; +import type { QoderCliEdition } from '../../core/types/settings'; import { getActiveQoderCliEdition, getQoderCliHomeDir } from '../config/cli-edition'; import type { SDKNativeMessage, SDKSessionReadResult } from './sdk-history-types'; @@ -35,16 +36,36 @@ export function isValidSessionId(sessionId: string): boolean { return isPathSafeId(sessionId); } -export function getSDKSessionPath(vaultPath: string, sessionId: string): string { +export function getSDKSessionPathForEdition( + vaultPath: string, + sessionId: string, + edition: QoderCliEdition, +): string { if (!isValidSessionId(sessionId)) { throw new Error(`Invalid session ID: ${sessionId}`); } - const projectsPath = getSDKProjectsPath(); + const projectsPath = path.join(getQoderCliHomeDir(edition), 'projects'); const encodedVault = encodeVaultPathForSDK(vaultPath); return path.join(projectsPath, encodedVault, `${sessionId}.jsonl`); } +export function getSDKSessionPath(vaultPath: string, sessionId: string): string { + return getSDKSessionPathForEdition(vaultPath, sessionId, getActiveQoderCliEdition()); +} + +export function sdkSessionExistsForEdition( + vaultPath: string, + sessionId: string, + edition: QoderCliEdition, +): boolean { + try { + return existsSync(getSDKSessionPathForEdition(vaultPath, sessionId, edition)); + } catch { + return false; + } +} + export function sdkSessionExists(vaultPath: string, sessionId: string): boolean { try { const sessionPath = getSDKSessionPath(vaultPath, sessionId); diff --git a/src/qoder/history/session-edition-filter.ts b/src/qoder/history/session-edition-filter.ts new file mode 100644 index 0000000..3f65965 --- /dev/null +++ b/src/qoder/history/session-edition-filter.ts @@ -0,0 +1,66 @@ +import type { SessionMetadata } from '../../core/types'; +import type { QoderCliEdition } from '../../core/types/settings'; + +/** Resume session id mirroring the plugin's load-time fallback (`sessionId ?? id`). */ +function resumeSessionId(meta: SessionMetadata): string | null { + return meta.sessionId !== undefined ? meta.sessionId : meta.id; +} + +/** Where a session's history file lives, relative to the active edition. */ +export type SessionEditionLocation = 'active' | 'other' | 'unknown'; + +/** + * Selects the session metadata visible to an edition. Sessions stamped with + * an edition only appear under that edition. Legacy metadata (no `edition` + * field) stays visible unless its history file provably lives under the other + * edition's config root, so sessions whose files are missing altogether are + * never silently dropped. + */ +export function selectMetadataForEdition( + metadata: SessionMetadata[], + edition: QoderCliEdition, + sessionExistsInOtherEdition: (sessionId: string) => boolean, +): SessionMetadata[] { + return metadata.filter((meta) => { + if (meta.edition !== undefined) { + return meta.edition === edition; + } + + const sessionId = resumeSessionId(meta); + if (!sessionId || meta.sessionId === null) { + // No history file was ever persisted; keep it under the active edition. + return true; + } + return !sessionExistsInOtherEdition(sessionId); + }); +} + +/** + * One-shot edition attribution for legacy metadata. Returns the owning + * edition when the history file location proves it, or `undefined` when the + * metadata is already stamped or its files are missing everywhere (left + * unstamped so a later pass can re-evaluate once files reappear). + */ +export function resolveLegacySessionEdition( + meta: SessionMetadata, + activeEdition: QoderCliEdition, + locateSession: (sessionId: string) => SessionEditionLocation, +): QoderCliEdition | undefined { + if (meta.edition !== undefined) { + return meta.edition; + } + + const sessionId = resumeSessionId(meta); + if (!sessionId || meta.sessionId === null) { + return undefined; + } + + const location = locateSession(sessionId); + if (location === 'active') { + return activeEdition; + } + if (location === 'other') { + return activeEdition === 'cn' ? 'global' : 'cn'; + } + return undefined; +} diff --git a/tests/unit/features/settings/ui/qoder-settings-tab.test.ts b/tests/unit/features/settings/ui/qoder-settings-tab.test.ts index b4cb86f..d226574 100644 --- a/tests/unit/features/settings/ui/qoder-settings-tab.test.ts +++ b/tests/unit/features/settings/ui/qoder-settings-tab.test.ts @@ -320,13 +320,18 @@ function createPlugin(overrides: Record = {}): any { getModelOptions: jest.fn().mockReturnValue([]), }, mcpStorage: {}, - pluginManager: {}, + pluginManager: { + loadPlugins: jest.fn().mockResolvedValue(undefined), + }, }, saveSettings: mockSaveSettings, normalizeModelVariantSettings: jest.fn(() => false), + reloadConversationIndex: jest.fn().mockResolvedValue(undefined), getView: jest.fn(() => ({ getTabManager: jest.fn(() => ({ broadcastToAllTabs: jest.fn().mockResolvedValue(undefined), + getAllTabs: jest.fn(() => []), + closeTab: jest.fn().mockResolvedValue(true), })), })), app: { @@ -387,6 +392,28 @@ describe('QoderSettingsTab', () => { expect(cliPathInput.placeholder).toContain('qodercli'); }); + it('force-closes all open tabs when switching editions', async () => { + const plugin = createPlugin(); + const closeTab = jest.fn().mockResolvedValue(true); + const getAllTabs = jest.fn(() => [{ id: 'tab-1' }, { id: 'tab-2' }]); + plugin.getView = jest.fn(() => ({ + getTabManager: jest.fn(() => ({ getAllTabs, closeTab })), + })); + + renderQoderCliPathSetting(createContainer(), { plugin }); + + const editionDropdown = findSetting('settings.cliEdition.name').dropdownComponents[0]; + await editionDropdown.onChangeCallback?.('cn'); + + expect(closeTab).toHaveBeenCalledWith('tab-1', true); + expect(closeTab).toHaveBeenCalledWith('tab-2', true); + // Tabs close before the new edition activates so saves stamp the outgoing one. + expect(closeTab.mock.invocationCallOrder[0]) + .toBeLessThan(mockSaveSettings.mock.invocationCallOrder[0]); + expect(plugin.settings.qoder.edition).toBe('cn'); + expect(plugin.reloadConversationIndex).toHaveBeenCalled(); + }); + it('does not duplicate the toolbar permission selector in settings', () => { const plugin = createPlugin(); const context = createContext(plugin); diff --git a/tests/unit/qoder/history/session-edition-filter.test.ts b/tests/unit/qoder/history/session-edition-filter.test.ts new file mode 100644 index 0000000..d0e40e7 --- /dev/null +++ b/tests/unit/qoder/history/session-edition-filter.test.ts @@ -0,0 +1,113 @@ +import type { SessionMetadata } from '@/core/types'; +import { + resolveLegacySessionEdition, + selectMetadataForEdition, +} from '@/qoder/history/session-edition-filter'; + +function meta(overrides: Partial): SessionMetadata { + return { + id: 'conv-1', + title: 'Test', + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +describe('selectMetadataForEdition', () => { + it('keeps only sessions stamped with the active edition', () => { + const metadata = [ + meta({ id: 'global-session', edition: 'global' }), + meta({ id: 'cn-session', edition: 'cn' }), + ]; + + const visible = selectMetadataForEdition(metadata, 'global', () => true); + + expect(visible.map(m => m.id)).toEqual(['global-session']); + }); + + it('shows cn-stamped sessions only under the cn edition', () => { + const metadata = [ + meta({ id: 'global-session', edition: 'global' }), + meta({ id: 'cn-session', edition: 'cn' }), + ]; + + const visible = selectMetadataForEdition(metadata, 'cn', () => true); + + expect(visible.map(m => m.id)).toEqual(['cn-session']); + }); + + it('keeps legacy metadata whose history is not found under the other edition', () => { + const metadata = [meta({ id: 'legacy', sessionId: 'session-abc' })]; + const existsInOther = jest.fn(() => false); + + const visible = selectMetadataForEdition(metadata, 'cn', existsInOther); + + expect(visible.map(m => m.id)).toEqual(['legacy']); + expect(existsInOther).toHaveBeenCalledWith('session-abc'); + }); + + it('hides legacy metadata whose history file lives under the other edition', () => { + const metadata = [meta({ id: 'legacy', sessionId: 'session-abc' })]; + + const visible = selectMetadataForEdition(metadata, 'cn', () => true); + + expect(visible).toEqual([]); + }); + + it('falls back to the metadata id when sessionId was never recorded', () => { + const metadata = [meta({ id: 'legacy' })]; + const existsInOther = jest.fn(() => false); + + const visible = selectMetadataForEdition(metadata, 'global', existsInOther); + + expect(visible.map(m => m.id)).toEqual(['legacy']); + expect(existsInOther).toHaveBeenCalledWith('legacy'); + }); + + it('keeps legacy metadata whose session id was cleared', () => { + const metadata = [meta({ id: 'legacy', sessionId: null })]; + + const visible = selectMetadataForEdition(metadata, 'cn', () => false); + + expect(visible.map(m => m.id)).toEqual(['legacy']); + }); +}); + +describe('resolveLegacySessionEdition', () => { + it('keeps the existing stamp untouched', () => { + const target = meta({ id: 'stamped', edition: 'cn' }); + + const resolved = resolveLegacySessionEdition(target, 'global', () => 'active'); + + expect(resolved).toBe('cn'); + }); + + it('attributes legacy metadata to the active edition when its file is there', () => { + const target = meta({ id: 'legacy', sessionId: 'session-abc' }); + + const resolved = resolveLegacySessionEdition(target, 'global', () => 'active'); + + expect(resolved).toBe('global'); + }); + + it('attributes legacy metadata to the other edition when its file is there', () => { + const target = meta({ id: 'legacy', sessionId: 'session-abc' }); + + expect(resolveLegacySessionEdition(target, 'global', () => 'other')).toBe('cn'); + expect(resolveLegacySessionEdition(target, 'cn', () => 'other')).toBe('global'); + }); + + it('leaves metadata unstamped when files are missing everywhere', () => { + const target = meta({ id: 'legacy', sessionId: 'session-abc' }); + + const resolved = resolveLegacySessionEdition(target, 'global', () => 'unknown'); + + expect(resolved).toBeUndefined(); + }); + + it('leaves metadata without a usable session id unstamped', () => { + expect(resolveLegacySessionEdition(meta({ id: 'legacy', sessionId: null }), 'global', () => 'active')) + .toBeUndefined(); + }); +});