From e66c94a390aada249affbabda27dee9db788c734 Mon Sep 17 00:00:00 2001 From: priva7eer Date: Sat, 17 Oct 2026 10:11:48 +0200 Subject: [PATCH] Follow active conversation tabs and invalidate stale chat selection --- CHANGELOG.md | 6 +++ README.md | 7 ++++ extension.js | 75 +++++++++++++++++++++++++++++++++++--- test/extension.test.cjs | 81 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 163 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 848f26d..39db911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to Codex Extras are documented in this file. +## Unreleased + +- Synchronize chat selection with the active conversation tab across editor + groups without stealing focus. Clear stale selections for unresolved new + panels; document the official Codex extension's generic new-tab title limit. + ## 1.1.0 - Add a Codex Chats Activity Bar panel with local chat history, automatic diff --git a/README.md b/README.md index b5c5777..994b83e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,13 @@ The official Codex extension is declared as a dependency and is installed automa ## Settings +The chat list follows the active conversation editor without moving keyboard +focus. New Codex panels keep a generic route until opened as a specific +conversation; Extras clears stale selection rather than guessing which chat is +inside them. If a new tab still says **Codex**, open its named entry in the list +to use a conversation-specific tab. Tab titles are owned by the official Codex +extension and cannot be renamed by Extras through the VS Code API. + | Setting | Default | Description | | --- | --- | --- | | `codexExtras.showNewAgentButton` | `true` | Shows the New Codex Agent button in the editor toolbar. | diff --git a/extension.js b/extension.js index aae4c3d..74d3955 100644 --- a/extension.js +++ b/extension.js @@ -28,10 +28,11 @@ let panelSequence = 0; let openQueue = Promise.resolve(); class CodexChatItem extends vscode.TreeItem { - constructor(session) { + constructor(session, selectionRevision = 0) { super(session.title, vscode.TreeItemCollapsibleState.None); - this.id = session.id; + this.sessionId = session.id; + this.id = `${session.id}:${selectionRevision}`; this.description = [session.workspaceName, formatUpdatedAt(session.updatedAt)] .filter(Boolean) .join(" ยท "); @@ -82,6 +83,8 @@ class CodexChatHistoryProvider { this.sessionsDirectory = sessionsDirectory; this.getWorkspaceFolders = getWorkspaceFolders; this.workspaceBySessionId = new Map(); + this.selectionRevision = 0; + this.groups = []; this.changeEmitter = new vscode.EventEmitter(); this.statusEmitter = new vscode.EventEmitter(); this.onDidChangeTreeData = this.changeEmitter.event; @@ -96,9 +99,21 @@ class CodexChatHistoryProvider { return item; } + getParent(item) { + return item instanceof CodexChatItem + ? this.groups.find((group) => group.sessions.some((session) => session.id === item.sessionId)) + : undefined; + } + + findChat(sessionId) { + const session = this.groups.flatMap((group) => group.sessions) + .find((session) => session.id === sessionId); + return session ? new CodexChatItem(session, this.selectionRevision) : undefined; + } + async getChildren(item) { if (item instanceof CodexChatGroupItem) { - return item.sessions.map((session) => new CodexChatItem(session)); + return item.sessions.map((session) => new CodexChatItem(session, this.selectionRevision)); } if (item) { @@ -126,9 +141,11 @@ class CodexChatHistoryProvider { workspacePath: this.workspaceBySessionId.get(session.id), })); + this.groups = createChatGroups(enrichedSessions, workspaceFolders); this.statusEmitter.fire(undefined); - return createChatGroups(enrichedSessions, workspaceFolders); + return this.groups; } catch (error) { + this.groups = []; if (error?.code === "ENOENT") { this.statusEmitter.fire(undefined); return []; @@ -294,7 +311,7 @@ function createConversationUri(sessionId) { } function openCodexChat(session) { - const sessionId = typeof session === "string" ? session : session?.id; + const sessionId = typeof session === "string" ? session : session?.sessionId ?? session?.id; if ( typeof sessionId !== "string" || @@ -364,8 +381,49 @@ function activate(context) { treeDataProvider: historyProvider, showCollapseAll: false, }); + let activeTab; + let activeRoute; + let selectionTimer; + let disposed = false; + const synchronizeSelection = () => { + clearTimeout(selectionTimer); + // Run after the provider has returned its children. reveal() waits for any + // pending tree refresh, so awaiting it inside getChildren would deadlock. + selectionTimer = setTimeout(async () => { + if (disposed || !historyView.visible) return; + const tab = vscode.window.tabGroups.activeTabGroup?.activeTab; + const uri = tab?.input?.uri; + if (uri?.scheme !== CODEX_NEW_PANEL_SCHEME || uri.authority !== CODEX_ROUTE_AUTHORITY) return; + const match = /^\/local\/([a-zA-Z0-9_-]+)$/.exec(uri.path); + const item = match && historyProvider.findChat(match[1]); + if (!item || historyView.selection?.[0]?.id === item.id) return; + try { + await historyView.reveal(item, { select: true, focus: false, expand: false }); + } catch { + // A tab switch, archive, or refresh may remove the row while revealing. + } + }, 0); + }; + const activeTabChanged = () => { + const tab = vscode.window.tabGroups.activeTabGroup?.activeTab; + const uri = tab?.input?.uri; + const route = uri ? JSON.stringify([uri.scheme, uri.authority, uri.path, uri.query]) : undefined; + if (tab !== activeTab || route !== activeRoute) { + activeTab = tab; + activeRoute = route; + // TreeView has no public clearSelection. Replace leaf identities on an + // editor switch so an unresolved new panel cannot retain an old chat's + // selection. Group identities and collapse state remain stable. + historyProvider.selectionRevision++; + historyProvider.refresh(); + } + synchronizeSelection(); + }; + const tabSubscription = vscode.window.tabGroups.onDidChangeTabs(activeTabChanged); + const tabGroupSubscription = vscode.window.tabGroups.onDidChangeTabGroups(activeTabChanged); const statusSubscription = historyProvider.onDidChangeStatus((message) => { historyView.message = message; + synchronizeSelection(); }); const visibilitySubscription = historyView.onDidChangeVisibility( ({ visible }) => { @@ -377,6 +435,7 @@ function activate(context) { const workspaceSubscription = vscode.workspace.onDidChangeWorkspaceFolders( () => historyProvider.refresh(), ); + activeTabChanged(); context.subscriptions.push( vscode.commands.registerCommand(COMMAND_ID, openNewChat), @@ -390,6 +449,12 @@ function activate(context) { statusSubscription, visibilitySubscription, workspaceSubscription, + tabSubscription, + tabGroupSubscription, + { dispose() { + disposed = true; + clearTimeout(selectionTimer); + } }, watchSessionIndex(sessionIndexPath, () => historyProvider.refresh()), ); } diff --git a/test/extension.test.cjs b/test/extension.test.cjs index 24202a6..db1e200 100644 --- a/test/extension.test.cjs +++ b/test/extension.test.cjs @@ -6,6 +6,7 @@ const Module = require("node:module"); const os = require("node:os"); const path = require("node:path"); const test = require("node:test"); +const { setTimeout: delay } = require("node:timers/promises"); const extensionPath = path.resolve(__dirname, "..", "extension.js"); const originalLoad = Module._load; @@ -62,6 +63,9 @@ function createHarness(t, tabs = [], options = {}) { } } + const tabsChanged = new EventEmitter(); + const tabGroupsChanged = new EventEmitter(); + const tabGroup = { tabs, activeTab: options.activeTab }; const vscode = { commands: { registerCommand(command, handler) { @@ -108,12 +112,22 @@ function createHarness(t, tabs = [], options = {}) { window: { activeTextEditor: undefined, tabGroups: { - all: [{ tabs }], + all: [tabGroup], + activeTabGroup: tabGroup, + onDidChangeTabs: tabsChanged.event, + onDidChangeTabGroups: tabGroupsChanged.event, }, createTreeView(id, options) { const view = { id, options, + visible: true, + selection: [], + reveals: [], + async reveal(item, revealOptions) { + this.reveals.push({ item, options: revealOptions }); + if (revealOptions.select) this.selection = [item]; + }, dispose() {}, onDidChangeVisibility() { return disposable; @@ -151,6 +165,10 @@ function createHarness(t, tabs = [], options = {}) { calls, commandHandlers, treeViews, + setActiveTab(tab, groupEvent = false) { + tabGroup.activeTab = tab; + (groupEvent ? tabGroupsChanged : tabsChanged).fire({}); + }, runCommand: (command = "codexExtras.newCodexAgent", ...args) => commandHandlers.get(command)(...args), }; @@ -223,6 +241,67 @@ test("does not treat an existing conversation as an empty panel", async (t) => { assert.deepEqual(harness.calls, [["chatgpt.newCodexPanel"]]); }); +test("tracks the active conversation across tab groups without moving editor focus", async (t) => { + const codexHome = await mkdtemp(path.join(os.tmpdir(), "codex-extras-active-")); + t.after(() => rm(codexHome, { recursive: true, force: true })); + const index = path.join(codexHome, "session_index.jsonl"); + await writeFile(index, ["first", "second"].map((id) => JSON.stringify({ id, thread_name: id })).join("\n")); + const conversationTab = (id) => ({ input: { uri: { + scheme: "openai-codex", authority: "route", path: `/local/${id}`, + } } }); + const first = conversationTab("first"); + const harness = createHarness(t, [first], { codexHome, workspaceFolders: [], activeTab: first }); + const view = harness.treeViews[0]; + const provider = view.options.treeDataProvider; + await provider.getChildren(); + await delay(25); + assert.equal(view.selection[0].sessionId, "first"); + assert.equal(provider.getParent(view.selection[0]).id, "all-chats"); + assert.deepEqual(view.reveals.at(-1).options, { select: true, focus: false, expand: false }); + await harness.runCommand("codexExtras.openChat", view.selection[0]); + assert.equal(harness.calls.at(-1)[1].path, "/local/first", "inline actions use the session ID, not the tree identity"); + harness.calls.length = 0; + const rootId = provider.groups[0].id; + harness.setActiveTab(conversationTab("second"), true); + await provider.getChildren(); + await delay(25); + assert.equal(view.selection[0].sessionId, "second"); + assert.equal(provider.groups[0].id, rootId, "tab switches preserve group identity"); + const previousSelection = view.selection[0].id; + const revealCount = view.reveals.length; + harness.setActiveTab(emptyCodexTab()); + const [group] = await provider.getChildren(); + const rows = await provider.getChildren(group); + await delay(25); + assert.ok(rows.every((row) => row.id !== previousSelection), "new panels invalidate stale native selection"); + assert.equal(view.reveals.length, revealCount, "never guess a new panel's conversation from its title or timestamp"); + assert.deepEqual(harness.calls, [], "selection sync must not open, close, or rename editors"); +}); + +test("reconciles a known active tab after its session appears or title changes in the index", async (t) => { + const codexHome = await mkdtemp(path.join(os.tmpdir(), "codex-extras-active-index-")); + t.after(() => rm(codexHome, { recursive: true, force: true })); + const index = path.join(codexHome, "session_index.jsonl"); + await writeFile(index, ""); + const activeTab = { input: { uri: { scheme: "openai-codex", authority: "route", path: "/local/new-session" } } }; + const harness = createHarness(t, [activeTab], { codexHome, workspaceFolders: [], activeTab }); + const view = harness.treeViews[0]; + const provider = view.options.treeDataProvider; + await provider.getChildren(); + await delay(25); + assert.equal(view.reveals.length, 0); + await writeFile(index, JSON.stringify({ id: "new-session", thread_name: "Generated title" })); + await provider.getChildren(); + await delay(25); + assert.equal(view.selection[0].label, "Generated title"); + const selectionId = view.selection[0].id; + provider.refresh(); + await provider.getChildren(); + await delay(25); + assert.equal(view.selection[0].id, selectionId, "status refreshes keep leaf identity stable"); + assert.equal(view.reveals.length, 1, "status refreshes must not repeatedly scroll the list"); +}); + test("registers the Codex chat history tree view", (t) => { const harness = createHarness(t);