Skip to content
Open
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: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
75 changes: 70 additions & 5 deletions extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(" · ");
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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 [];
Expand Down Expand Up @@ -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" ||
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -377,6 +435,7 @@ function activate(context) {
const workspaceSubscription = vscode.workspace.onDidChangeWorkspaceFolders(
() => historyProvider.refresh(),
);
activeTabChanged();

context.subscriptions.push(
vscode.commands.registerCommand(COMMAND_ID, openNewChat),
Expand All @@ -390,6 +449,12 @@ function activate(context) {
statusSubscription,
visibilitySubscription,
workspaceSubscription,
tabSubscription,
tabGroupSubscription,
{ dispose() {
disposed = true;
clearTimeout(selectionTimer);
} },
watchSessionIndex(sessionIndexPath, () => historyProvider.refresh()),
);
}
Expand Down
81 changes: 80 additions & 1 deletion test/extension.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
};
Expand Down Expand Up @@ -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);

Expand Down