Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/core/types/chat.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
}

/**
Expand Down
19 changes: 14 additions & 5 deletions src/features/settings/ui/qoder-settings-tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
});
});
Expand Down
105 changes: 88 additions & 17 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -395,6 +397,7 @@ export default class QoderianPlugin extends Plugin {
enabledMcpServers: conversation.enabledMcpServers,
usage: conversation.usage,
resumeAtMessageId: conversation.resumeAtMessageId,
edition: conversation.edition ?? getActiveQoderCliEdition(),
};
}

Expand All @@ -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<Conversation[]> {
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 {
Expand All @@ -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<void> {
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<void> {
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 {
Expand Down Expand Up @@ -511,6 +581,7 @@ export default class QoderianPlugin extends Plugin {
updatedAt: Date.now(),
sessionId: sessionId ?? null,
messages: [],
edition: getActiveQoderCliEdition(),
};

this.conversations.unshift(conversation);
Expand Down
25 changes: 23 additions & 2 deletions src/qoder/history/sdk-session-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
66 changes: 66 additions & 0 deletions src/qoder/history/session-edition-filter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
29 changes: 28 additions & 1 deletion tests/unit/features/settings/ui/qoder-settings-tab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,13 +320,18 @@ function createPlugin(overrides: Record<string, unknown> = {}): 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: {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading