Skip to content

Commit 22ffd48

Browse files
luoxuanzaoQoder-AI
andauthored
feat: isolate session history per Qoder CLI edition (#29)
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) <qoder_ai@qoder.com>
1 parent 7b4e20a commit 22ffd48

8 files changed

Lines changed: 342 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ version with its date and start a fresh empty `[Unreleased]` above it.
2626
international build (`qodercli`, config under `~/.qoder`) or the
2727
China build (`qoderclicn`, config under `~/.qoder-cn`). Auto-
2828
detection, session history, global plugins, and login hints all
29-
follow the selected edition.
29+
follow the selected edition. Session history is isolated per
30+
edition: each conversation is stamped with the edition that owns
31+
it, switching editions force-closes all open tabs, and the history
32+
list shows only the active edition's sessions (pre-existing
33+
sessions are attributed by where their history files live).
3034
- Per-model context and thinking editor in the model selector,
3135
mirroring the Qoder IDE: hovering a model row reveals an edit
3236
affordance that opens a side editor card with context window

src/core/types/chat.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ToolUseResult } from './diff';
2+
import type { QoderCliEdition } from './settings';
23
import type { SubagentInfo, SubagentMode, ToolCallInfo } from './tools';
34

45
/** Fork origin reference: identifies the source session and checkpoint. */
@@ -97,6 +98,8 @@ export interface Conversation {
9798
enabledMcpServers?: string[];
9899
/** Assistant checkpoint identifier for resumeAtMessageId after rewind. */
99100
resumeAtMessageId?: string;
101+
/** Qoder CLI edition whose config root stores this conversation's history. */
102+
edition?: QoderCliEdition;
100103
}
101104

102105
/** Lightweight conversation metadata for the history dropdown. */
@@ -134,6 +137,8 @@ export interface SessionMetadata {
134137
usage?: UsageInfo;
135138
/** Assistant checkpoint identifier for resumeAtMessageId after rewind. */
136139
resumeAtMessageId?: string;
140+
/** Qoder CLI edition owning the session history (absent in legacy files). */
141+
edition?: QoderCliEdition;
137142
}
138143

139144
/**

src/features/settings/ui/qoder-settings-tab.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,9 @@ export function renderQoderCliPathControl(
163163
/**
164164
* Attaches the CLI edition dropdown to `setting`. Switching editions changes
165165
* the executable name and the CLI's user config root, so the resolver cache
166-
* is dropped and every tab restarts, mirroring a CLI path change.
166+
* is dropped, every open tab is force-closed (users must not continue a
167+
* conversation owned by the other edition), the conversation index is
168+
* rebuilt for the new edition, and a blank tab takes over.
167169
*/
168170
export function renderQoderCliEditionControl(
169171
setting: Setting,
@@ -179,14 +181,21 @@ export function renderQoderCliEditionControl(
179181
.setValue(getQoderSettings(settingsBag).edition)
180182
.onChange(async (value) => {
181183
const edition = normalizeQoderCliEdition(value);
184+
// Close every tab (even streaming ones) before activating the new
185+
// edition so no conversation from the previous edition stays open and
186+
// in-flight saves still stamp the outgoing edition. Closing the last
187+
// tab spawns a blank one.
188+
const tabManager = context.plugin.getView()?.getTabManager();
189+
if (tabManager) {
190+
for (const tab of [...tabManager.getAllTabs()]) {
191+
await tabManager.closeTab(tab.id, true);
192+
}
193+
}
182194
updateQoderSettings(settingsBag, { edition });
183195
await context.plugin.saveSettings();
184196
qoderWorkspace.cliResolver.reset();
185197
await qoderWorkspace.pluginManager.loadPlugins();
186-
const view = context.plugin.getView();
187-
await view?.getTabManager()?.broadcastToAllTabs(
188-
(service) => Promise.resolve(service.cleanup())
189-
);
198+
await context.plugin.reloadConversationIndex();
190199
void qoderWorkspace.agentCatalog.refresh();
191200
});
192201
});

src/main.ts

Lines changed: 88 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Editor, WorkspaceLeaf } from 'obsidian';
66
import { addIcon, MarkdownView, Notice, Plugin } from 'obsidian';
77

88
import { QoderianStorage } from './app/storage/app-storage';
9-
import { beginRestoreReport } from './core/diagnostics/restore-report';
9+
import { beginRestoreReport, reportRestoreIssue } from './core/diagnostics/restore-report';
1010
import { buildCursorContext } from './core/editor/editor-context';
1111
import { getVaultPath } from './core/fs/path';
1212
import type {
@@ -25,9 +25,11 @@ import { type InlineEditContext, InlineEditModal } from './features/inline-edit/
2525
import { QoderianSettingTab } from './features/settings/settings-tab';
2626
import { setLocale, t } from './i18n/i18n';
2727
import type { Locale } from './i18n/types';
28-
import { setActiveQoderCliEdition } from './qoder/config/cli-edition';
28+
import { getActiveQoderCliEdition, setActiveQoderCliEdition } from './qoder/config/cli-edition';
2929
import { normalizeQoderSettings } from './qoder/config/qoder-settings-reconciler';
3030
import { getQoderSettings } from './qoder/config/settings';
31+
import { sdkSessionExistsForEdition } from './qoder/history/sdk-session-paths';
32+
import { resolveLegacySessionEdition, selectMetadataForEdition } from './qoder/history/session-edition-filter';
3133
import { extractUserDisplayContent } from './qoder/prompt/context/prompt-context';
3234
import {
3335
createQoderServices,
@@ -395,6 +397,7 @@ export default class QoderianPlugin extends Plugin {
395397
enabledMcpServers: conversation.enabledMcpServers,
396398
usage: conversation.usage,
397399
resumeAtMessageId: conversation.resumeAtMessageId,
400+
edition: conversation.edition ?? getActiveQoderCliEdition(),
398401
};
399402
}
400403

@@ -415,7 +418,45 @@ export default class QoderianPlugin extends Plugin {
415418
const didNormalizeModelVariants = this.normalizeModelVariantSettings();
416419

417420
const allMetadata = await this.storage.sessions.listMetadata();
418-
this.conversations = allMetadata.map(meta => {
421+
await this.migrateLegacySessionEditions(allMetadata);
422+
this.conversations = await this.buildConversationIndex(allMetadata);
423+
setLocale(this.settings.locale as Locale);
424+
425+
if (didNormalizeModelVariants) {
426+
await this.saveSettings();
427+
}
428+
}
429+
430+
normalizeModelVariantSettings(): boolean {
431+
return normalizeQoderSettings(this.settings);
432+
}
433+
434+
async saveSettings() {
435+
this.syncActiveQoderCliEdition();
436+
await this.storage.saveQoderianSettings(this.settings);
437+
}
438+
439+
/** Keeps the edition-aware path helpers aligned with the persisted settings. */
440+
private syncActiveQoderCliEdition() {
441+
setActiveQoderCliEdition(getQoderSettings(this.settings).edition);
442+
}
443+
444+
/**
445+
* Builds the in-memory conversation index from session metadata, keeping
446+
* only the sessions owned by the active CLI edition.
447+
*/
448+
private async buildConversationIndex(allMetadata: SessionMetadata[]): Promise<Conversation[]> {
449+
const edition = getActiveQoderCliEdition();
450+
const otherEdition = edition === 'cn' ? 'global' : 'cn';
451+
const vaultPath = getVaultPath(this.app);
452+
const visible = selectMetadataForEdition(
453+
allMetadata,
454+
edition,
455+
(sessionId) => vaultPath !== null
456+
&& sdkSessionExistsForEdition(vaultPath, sessionId, otherEdition),
457+
);
458+
459+
return visible.map(meta => {
419460
const resumeSessionId = meta.sessionId !== undefined ? meta.sessionId : meta.id;
420461

421462
return {
@@ -433,29 +474,58 @@ export default class QoderianPlugin extends Plugin {
433474
usage: meta.usage,
434475
titleGenerationStatus: meta.titleGenerationStatus,
435476
resumeAtMessageId: meta.resumeAtMessageId,
477+
edition: meta.edition ?? edition,
436478
};
437479
}).sort(
438480
(a, b) => (b.lastResponseAt ?? b.updatedAt) - (a.lastResponseAt ?? a.updatedAt)
439481
);
440-
setLocale(this.settings.locale as Locale);
441-
442-
if (didNormalizeModelVariants) {
443-
await this.saveSettings();
444-
}
445482
}
446483

447-
normalizeModelVariantSettings(): boolean {
448-
return normalizeQoderSettings(this.settings);
484+
/** Rebuilds the conversation index after the CLI edition changes. */
485+
async reloadConversationIndex(): Promise<void> {
486+
const allMetadata = await this.storage.sessions.listMetadata();
487+
this.conversations = await this.buildConversationIndex(allMetadata);
449488
}
450489

451-
async saveSettings() {
452-
this.syncActiveQoderCliEdition();
453-
await this.storage.saveQoderianSettings(this.settings);
454-
}
490+
/**
491+
* One-shot upgrade for metadata written before the `edition` field existed:
492+
* stamps each legacy session with the edition whose config root holds its
493+
* history file, so future loads no longer depend on filesystem probing.
494+
* Sessions whose files are missing everywhere stay unstamped.
495+
*/
496+
private async migrateLegacySessionEditions(allMetadata: SessionMetadata[]): Promise<void> {
497+
const edition = getActiveQoderCliEdition();
498+
const otherEdition = edition === 'cn' ? 'global' : 'cn';
499+
const vaultPath = getVaultPath(this.app);
500+
if (vaultPath === null) {
501+
return;
502+
}
455503

456-
/** Keeps the edition-aware path helpers aligned with the persisted settings. */
457-
private syncActiveQoderCliEdition() {
458-
setActiveQoderCliEdition(getQoderSettings(this.settings).edition);
504+
for (const meta of allMetadata) {
505+
if (meta.edition !== undefined) {
506+
continue;
507+
}
508+
const resolved = resolveLegacySessionEdition(meta, edition, (sessionId) => {
509+
if (sdkSessionExistsForEdition(vaultPath, sessionId, edition)) {
510+
return 'active';
511+
}
512+
return sdkSessionExistsForEdition(vaultPath, sessionId, otherEdition)
513+
? 'other'
514+
: 'unknown';
515+
});
516+
if (resolved === undefined) {
517+
continue;
518+
}
519+
try {
520+
await this.storage.sessions.saveMetadata({ ...meta, edition: resolved });
521+
meta.edition = resolved;
522+
} catch {
523+
reportRestoreIssue(
524+
'metadata',
525+
`Failed to stamp edition on session metadata "${meta.id}"; it will be re-attempted on next load.`,
526+
);
527+
}
528+
}
459529
}
460530

461531
getResolvedQoderCliPath(): string | null {
@@ -511,6 +581,7 @@ export default class QoderianPlugin extends Plugin {
511581
updatedAt: Date.now(),
512582
sessionId: sessionId ?? null,
513583
messages: [],
584+
edition: getActiveQoderCliEdition(),
514585
};
515586

516587
this.conversations.unshift(conversation);

src/qoder/history/sdk-session-paths.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync } from 'fs';
22
import * as fs from 'fs/promises';
33
import * as path from 'path';
44

5+
import type { QoderCliEdition } from '../../core/types/settings';
56
import { getActiveQoderCliEdition, getQoderCliHomeDir } from '../config/cli-edition';
67
import type { SDKNativeMessage, SDKSessionReadResult } from './sdk-history-types';
78

@@ -35,16 +36,36 @@ export function isValidSessionId(sessionId: string): boolean {
3536
return isPathSafeId(sessionId);
3637
}
3738

38-
export function getSDKSessionPath(vaultPath: string, sessionId: string): string {
39+
export function getSDKSessionPathForEdition(
40+
vaultPath: string,
41+
sessionId: string,
42+
edition: QoderCliEdition,
43+
): string {
3944
if (!isValidSessionId(sessionId)) {
4045
throw new Error(`Invalid session ID: ${sessionId}`);
4146
}
4247

43-
const projectsPath = getSDKProjectsPath();
48+
const projectsPath = path.join(getQoderCliHomeDir(edition), 'projects');
4449
const encodedVault = encodeVaultPathForSDK(vaultPath);
4550
return path.join(projectsPath, encodedVault, `${sessionId}.jsonl`);
4651
}
4752

53+
export function getSDKSessionPath(vaultPath: string, sessionId: string): string {
54+
return getSDKSessionPathForEdition(vaultPath, sessionId, getActiveQoderCliEdition());
55+
}
56+
57+
export function sdkSessionExistsForEdition(
58+
vaultPath: string,
59+
sessionId: string,
60+
edition: QoderCliEdition,
61+
): boolean {
62+
try {
63+
return existsSync(getSDKSessionPathForEdition(vaultPath, sessionId, edition));
64+
} catch {
65+
return false;
66+
}
67+
}
68+
4869
export function sdkSessionExists(vaultPath: string, sessionId: string): boolean {
4970
try {
5071
const sessionPath = getSDKSessionPath(vaultPath, sessionId);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { SessionMetadata } from '../../core/types';
2+
import type { QoderCliEdition } from '../../core/types/settings';
3+
4+
/** Resume session id mirroring the plugin's load-time fallback (`sessionId ?? id`). */
5+
function resumeSessionId(meta: SessionMetadata): string | null {
6+
return meta.sessionId !== undefined ? meta.sessionId : meta.id;
7+
}
8+
9+
/** Where a session's history file lives, relative to the active edition. */
10+
export type SessionEditionLocation = 'active' | 'other' | 'unknown';
11+
12+
/**
13+
* Selects the session metadata visible to an edition. Sessions stamped with
14+
* an edition only appear under that edition. Legacy metadata (no `edition`
15+
* field) stays visible unless its history file provably lives under the other
16+
* edition's config root, so sessions whose files are missing altogether are
17+
* never silently dropped.
18+
*/
19+
export function selectMetadataForEdition(
20+
metadata: SessionMetadata[],
21+
edition: QoderCliEdition,
22+
sessionExistsInOtherEdition: (sessionId: string) => boolean,
23+
): SessionMetadata[] {
24+
return metadata.filter((meta) => {
25+
if (meta.edition !== undefined) {
26+
return meta.edition === edition;
27+
}
28+
29+
const sessionId = resumeSessionId(meta);
30+
if (!sessionId || meta.sessionId === null) {
31+
// No history file was ever persisted; keep it under the active edition.
32+
return true;
33+
}
34+
return !sessionExistsInOtherEdition(sessionId);
35+
});
36+
}
37+
38+
/**
39+
* One-shot edition attribution for legacy metadata. Returns the owning
40+
* edition when the history file location proves it, or `undefined` when the
41+
* metadata is already stamped or its files are missing everywhere (left
42+
* unstamped so a later pass can re-evaluate once files reappear).
43+
*/
44+
export function resolveLegacySessionEdition(
45+
meta: SessionMetadata,
46+
activeEdition: QoderCliEdition,
47+
locateSession: (sessionId: string) => SessionEditionLocation,
48+
): QoderCliEdition | undefined {
49+
if (meta.edition !== undefined) {
50+
return meta.edition;
51+
}
52+
53+
const sessionId = resumeSessionId(meta);
54+
if (!sessionId || meta.sessionId === null) {
55+
return undefined;
56+
}
57+
58+
const location = locateSession(sessionId);
59+
if (location === 'active') {
60+
return activeEdition;
61+
}
62+
if (location === 'other') {
63+
return activeEdition === 'cn' ? 'global' : 'cn';
64+
}
65+
return undefined;
66+
}

tests/unit/features/settings/ui/qoder-settings-tab.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,13 +320,18 @@ function createPlugin(overrides: Record<string, unknown> = {}): any {
320320
getModelOptions: jest.fn().mockReturnValue([]),
321321
},
322322
mcpStorage: {},
323-
pluginManager: {},
323+
pluginManager: {
324+
loadPlugins: jest.fn().mockResolvedValue(undefined),
325+
},
324326
},
325327
saveSettings: mockSaveSettings,
326328
normalizeModelVariantSettings: jest.fn(() => false),
329+
reloadConversationIndex: jest.fn().mockResolvedValue(undefined),
327330
getView: jest.fn(() => ({
328331
getTabManager: jest.fn(() => ({
329332
broadcastToAllTabs: jest.fn().mockResolvedValue(undefined),
333+
getAllTabs: jest.fn(() => []),
334+
closeTab: jest.fn().mockResolvedValue(true),
330335
})),
331336
})),
332337
app: {
@@ -387,6 +392,28 @@ describe('QoderSettingsTab', () => {
387392
expect(cliPathInput.placeholder).toContain('qodercli');
388393
});
389394

395+
it('force-closes all open tabs when switching editions', async () => {
396+
const plugin = createPlugin();
397+
const closeTab = jest.fn().mockResolvedValue(true);
398+
const getAllTabs = jest.fn(() => [{ id: 'tab-1' }, { id: 'tab-2' }]);
399+
plugin.getView = jest.fn(() => ({
400+
getTabManager: jest.fn(() => ({ getAllTabs, closeTab })),
401+
}));
402+
403+
renderQoderCliPathSetting(createContainer(), { plugin });
404+
405+
const editionDropdown = findSetting('settings.cliEdition.name').dropdownComponents[0];
406+
await editionDropdown.onChangeCallback?.('cn');
407+
408+
expect(closeTab).toHaveBeenCalledWith('tab-1', true);
409+
expect(closeTab).toHaveBeenCalledWith('tab-2', true);
410+
// Tabs close before the new edition activates so saves stamp the outgoing one.
411+
expect(closeTab.mock.invocationCallOrder[0])
412+
.toBeLessThan(mockSaveSettings.mock.invocationCallOrder[0]);
413+
expect(plugin.settings.qoder.edition).toBe('cn');
414+
expect(plugin.reloadConversationIndex).toHaveBeenCalled();
415+
});
416+
390417
it('does not duplicate the toolbar permission selector in settings', () => {
391418
const plugin = createPlugin();
392419
const context = createContext(plugin);

0 commit comments

Comments
 (0)