diff --git a/.vscodeignore b/.vscodeignore index 47fabea..1e3b2f9 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -10,4 +10,5 @@ SECURITY.md assets/icon-source.png assets/icon-prompt.md package-lock.json +test/** *.vsix diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b5793..848f26d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to Codex Extras are documented in this file. +## 1.1.0 + +- Add a Codex Chats Activity Bar panel with local chat history, automatic + refresh, and pinned editor-tab opening. +- Show current-workspace chats first, separate chats from other workspaces, and + use a new geometric Activity Bar icon. +- Open a distinct empty Codex agent when the toolbar button is selected while + another empty agent is already open. + ## 1.0.1 - Add an original code-brackets-and-plus icon for the New Codex Agent toolbar button. diff --git a/README.md b/README.md index d27a1a9..b5c5777 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,28 @@ Small, focused workflow improvements for the official Codex extension in Visual ## Features +### Codex Chats panel + +Adds a **Codex Chats** icon to the Activity Bar. Open it to see local Codex +conversations ordered by their most recent activity. + +- Chats for the current workspace appear first in an expanded section. +- Chats from other workspaces stay in a separate collapsed section and show + their workspace name. +- Select a chat to open it as a pinned Codex editor tab. +- Start a new chat or refresh the list from the panel toolbar. +- New and renamed chats appear automatically while VS Code is running. + +The panel reads Codex's local session index from `$CODEX_HOME/session_index.jsonl` +when `CODEX_HOME` is set, or from `~/.codex/session_index.jsonl` otherwise. +It reads only the small metadata prefix needed to identify each chat's workspace; +transcript contents are not parsed or copied. + ### New Codex Agent toolbar button -Adds a visible code-brackets-and-plus action to the editor toolbar. Select it to execute the official Codex command `chatgpt.newCodexPanel` and open a new Codex agent. +Adds a visible code-brackets-and-plus action to the editor toolbar. Select it to +open a new Codex agent. If an empty Codex agent is already open, selecting the +button again opens another empty agent instead of focusing the existing one. The button is available without assigning a keyboard shortcut. @@ -42,12 +61,14 @@ For an unpublished development build: ```sh npm ci npm run package -code --install-extension codex-extras-1.0.1.vsix +code --install-extension codex-extras-1.1.0.vsix ``` ## Privacy -Codex Extras contains no telemetry or network requests. Its small runtime wrapper only delegates the toolbar action to a command provided by the official Codex extension. +Codex Extras contains no telemetry or network requests. It reads the local Codex +session index to populate the chat list and opens editor panels provided by the +official Codex extension. ## Contributing diff --git a/assets/sidebar.svg b/assets/sidebar.svg new file mode 100644 index 0000000..abc599d --- /dev/null +++ b/assets/sidebar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/RELEASING.md b/docs/RELEASING.md index e572e2c..b907c40 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -29,8 +29,8 @@ Never commit or paste the token into an issue, pull request, workflow file, or b 5. Tag the exact version and push it: ```sh - git tag v1.0.1 - git push origin v1.0.1 + git tag v1.1.0 + git push origin v1.1.0 ``` The release workflow verifies that the tag matches `package.json`, packages the VSIX, publishes it to the Visual Studio Marketplace, and creates a GitHub release containing the same VSIX. diff --git a/extension.js b/extension.js index e859cf3..aae4c3d 100644 --- a/extension.js +++ b/extension.js @@ -1,22 +1,397 @@ "use strict"; +const fs = require("node:fs"); +const { readFile } = require("node:fs/promises"); +const path = require("node:path"); const vscode = require("vscode"); +const { + getSessionIndexPath, + getSessionsDirectory, + isPathInsideWorkspace, + loadSessionWorkspaces, + parseSessionIndex, +} = require("./session-index"); const COMMAND_ID = "codexExtras.newCodexAgent"; +const NEW_CHAT_COMMAND_ID = "codexExtras.newChat"; +const OPEN_CHAT_COMMAND_ID = "codexExtras.openChat"; +const REFRESH_CHATS_COMMAND_ID = "codexExtras.refreshChats"; +const CHAT_HISTORY_VIEW_ID = "codexExtras.chatHistory"; const OFFICIAL_CODEX_COMMAND_ID = "chatgpt.newCodexPanel"; +const VSCODE_OPEN_WITH_COMMAND_ID = "vscode.openWith"; +const CODEX_EDITOR_VIEW_TYPE = "chatgpt.conversationEditor"; +const CODEX_NEW_PANEL_SCHEME = "openai-codex"; +const CODEX_ROUTE_AUTHORITY = "route"; +const CODEX_NEW_PANEL_PATH = "/extension/panel/new"; + +let panelSequence = 0; +let openQueue = Promise.resolve(); + +class CodexChatItem extends vscode.TreeItem { + constructor(session) { + super(session.title, vscode.TreeItemCollapsibleState.None); + + this.id = session.id; + this.description = [session.workspaceName, formatUpdatedAt(session.updatedAt)] + .filter(Boolean) + .join(" · "); + this.tooltip = [ + session.title, + session.workspacePath ? `Workspace: ${session.workspacePath}` : undefined, + session.updatedAt + ? `Updated: ${new Date(session.updatedAt).toLocaleString()}` + : undefined, + ] + .filter(Boolean) + .join("\n"); + this.iconPath = new vscode.ThemeIcon("comment-discussion"); + this.contextValue = "codexChat"; + this.command = { + command: OPEN_CHAT_COMMAND_ID, + title: "Open Codex Chat", + arguments: [session.id], + }; + this.accessibilityInformation = { + label: `${session.title}, Codex chat`, + }; + } +} + +class CodexChatGroupItem extends vscode.TreeItem { + constructor(label, sessions, options = {}) { + super( + label, + options.expanded + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed, + ); + + this.sessions = sessions; + this.id = options.id; + this.description = `${sessions.length} ${ + sessions.length === 1 ? "chat" : "chats" + }${options.detail ? ` · ${options.detail}` : ""}`; + this.iconPath = new vscode.ThemeIcon(options.icon ?? "folder"); + this.contextValue = "codexChatGroup"; + } +} + +class CodexChatHistoryProvider { + constructor(sessionIndexPath, sessionsDirectory, getWorkspaceFolders) { + this.sessionIndexPath = sessionIndexPath; + this.sessionsDirectory = sessionsDirectory; + this.getWorkspaceFolders = getWorkspaceFolders; + this.workspaceBySessionId = new Map(); + this.changeEmitter = new vscode.EventEmitter(); + this.statusEmitter = new vscode.EventEmitter(); + this.onDidChangeTreeData = this.changeEmitter.event; + this.onDidChangeStatus = this.statusEmitter.event; + } + + refresh() { + this.changeEmitter.fire(undefined); + } + + getTreeItem(item) { + return item; + } + + async getChildren(item) { + if (item instanceof CodexChatGroupItem) { + return item.sessions.map((session) => new CodexChatItem(session)); + } + + if (item) { + return []; + } + + try { + const contents = await readFile(this.sessionIndexPath, "utf8"); + const sessions = parseSessionIndex(contents); + const missingWorkspaceIds = sessions + .filter((session) => !this.workspaceBySessionId.has(session.id)) + .map((session) => session.id); + const loadedWorkspaces = await loadSessionWorkspaces( + this.sessionsDirectory, + missingWorkspaceIds, + ); + + for (const [sessionId, workspacePath] of loadedWorkspaces) { + this.workspaceBySessionId.set(sessionId, workspacePath); + } + + const workspaceFolders = this.getWorkspaceFolders(); + const enrichedSessions = sessions.map((session) => ({ + ...session, + workspacePath: this.workspaceBySessionId.get(session.id), + })); + + this.statusEmitter.fire(undefined); + return createChatGroups(enrichedSessions, workspaceFolders); + } catch (error) { + if (error?.code === "ENOENT") { + this.statusEmitter.fire(undefined); + return []; + } + + this.statusEmitter.fire("Codex chat history could not be loaded."); + return []; + } + } + + dispose() { + this.changeEmitter.dispose(); + this.statusEmitter.dispose(); + } +} + +function createChatGroups(sessions, workspaceFolders) { + if (sessions.length === 0) { + return []; + } + + if (workspaceFolders.length === 0) { + return [ + new CodexChatGroupItem( + "All Chats", + sessions.map((session) => ({ + ...session, + workspaceName: getWorkspaceName(session.workspacePath), + })), + { + expanded: true, + icon: "comment-discussion", + id: "all-chats", + }, + ), + ]; + } + + const currentSessions = []; + const otherSessions = []; + + for (const session of sessions) { + const currentFolder = workspaceFolders.find((folder) => + isPathInsideWorkspace(session.workspacePath, folder.uri.fsPath), + ); + + if (currentFolder) { + currentSessions.push({ + ...session, + workspaceName: undefined, + }); + } else { + otherSessions.push({ + ...session, + workspaceName: getWorkspaceName(session.workspacePath), + }); + } + } + + const currentWorkspaceDetail = + workspaceFolders.length === 1 + ? workspaceFolders[0].name + : `${workspaceFolders.length} folders`; + const groups = [ + new CodexChatGroupItem("Current Workspace", currentSessions, { + expanded: true, + icon: "root-folder-opened", + detail: currentWorkspaceDetail, + id: "current-workspace", + }), + ]; + + if (otherSessions.length > 0) { + groups.push( + new CodexChatGroupItem("Other Workspaces", otherSessions, { + expanded: false, + icon: "folder-library", + id: "other-workspaces", + }), + ); + } + + return groups; +} + +function getWorkspaceName(workspacePath) { + return workspacePath ? path.basename(workspacePath) : "Unknown workspace"; +} + +function formatUpdatedAt(updatedAt) { + if (!updatedAt) { + return undefined; + } + + const date = new Date(updatedAt); + const today = new Date(); + const sameDay = + date.getFullYear() === today.getFullYear() && + date.getMonth() === today.getMonth() && + date.getDate() === today.getDate(); + + return sameDay + ? date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + : date.toLocaleDateString([], { + year: date.getFullYear() === today.getFullYear() ? undefined : "numeric", + month: "short", + day: "numeric", + }); +} + +function hasOpenEmptyCodexPanel() { + return vscode.window.tabGroups.all.some((group) => + group.tabs.some((tab) => { + const uri = tab.input?.uri; + + return ( + uri?.scheme === CODEX_NEW_PANEL_SCHEME && + uri.authority === CODEX_ROUTE_AUTHORITY && + uri.path === CODEX_NEW_PANEL_PATH + ); + }), + ); +} + +function createUniqueNewPanelUri() { + panelSequence += 1; + + return vscode.Uri.from({ + scheme: CODEX_NEW_PANEL_SCHEME, + authority: CODEX_ROUTE_AUTHORITY, + path: CODEX_NEW_PANEL_PATH, + query: `codexExtras=${Date.now()}-${panelSequence}`, + }); +} + +async function openNewCodexAgent() { + if (!hasOpenEmptyCodexPanel()) { + return vscode.commands.executeCommand(OFFICIAL_CODEX_COMMAND_ID); + } + + const uri = createUniqueNewPanelUri(); + const viewColumn = + vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active; + + return vscode.commands.executeCommand( + VSCODE_OPEN_WITH_COMMAND_ID, + uri, + CODEX_EDITOR_VIEW_TYPE, + { + viewColumn, + preserveFocus: false, + preview: false, + }, + ); +} + +function createConversationUri(sessionId) { + return vscode.Uri.from({ + scheme: CODEX_NEW_PANEL_SCHEME, + authority: CODEX_ROUTE_AUTHORITY, + path: `/local/${sessionId}`, + }); +} + +function openCodexChat(session) { + const sessionId = typeof session === "string" ? session : session?.id; + + if ( + typeof sessionId !== "string" || + !/^[a-zA-Z0-9_-]+$/.test(sessionId) + ) { + return undefined; + } + + return vscode.commands.executeCommand( + VSCODE_OPEN_WITH_COMMAND_ID, + createConversationUri(sessionId), + CODEX_EDITOR_VIEW_TYPE, + { + viewColumn: vscode.ViewColumn.Active, + preserveFocus: false, + preview: false, + }, + ); +} + +function watchSessionIndex(sessionIndexPath, onChange) { + const listener = (current, previous) => { + if ( + current.mtimeMs !== previous.mtimeMs || + current.size !== previous.size || + current.ino !== previous.ino + ) { + onChange(); + } + }; + + fs.watchFile( + sessionIndexPath, + { interval: 1_000, persistent: false }, + listener, + ); + + return { + dispose() { + fs.unwatchFile(sessionIndexPath, listener); + }, + }; +} /** - * Registers the branded toolbar action and delegates its behavior to the - * official Codex extension. + * Registers the branded toolbar action and Codex chat history view. + * + * The official command uses a stable URI for a new Codex panel, so VS Code + * focuses an existing empty panel instead of opening another one. Preserve the + * official path for the first panel, then add a unique query value whenever an + * empty panel is already open. Codex ignores the query when resolving its route. * * @param {import("vscode").ExtensionContext} context */ function activate(context) { - const command = vscode.commands.registerCommand(COMMAND_ID, () => - vscode.commands.executeCommand(OFFICIAL_CODEX_COMMAND_ID), + const openNewChat = () => { + openQueue = openQueue.catch(() => undefined).then(openNewCodexAgent); + return openQueue; + }; + const sessionIndexPath = getSessionIndexPath(); + const historyProvider = new CodexChatHistoryProvider( + sessionIndexPath, + getSessionsDirectory(), + () => vscode.workspace.workspaceFolders ?? [], + ); + const historyView = vscode.window.createTreeView(CHAT_HISTORY_VIEW_ID, { + treeDataProvider: historyProvider, + showCollapseAll: false, + }); + const statusSubscription = historyProvider.onDidChangeStatus((message) => { + historyView.message = message; + }); + const visibilitySubscription = historyView.onDidChangeVisibility( + ({ visible }) => { + if (visible) { + historyProvider.refresh(); + } + }, + ); + const workspaceSubscription = vscode.workspace.onDidChangeWorkspaceFolders( + () => historyProvider.refresh(), ); - context.subscriptions.push(command); + context.subscriptions.push( + vscode.commands.registerCommand(COMMAND_ID, openNewChat), + vscode.commands.registerCommand(NEW_CHAT_COMMAND_ID, openNewChat), + vscode.commands.registerCommand(OPEN_CHAT_COMMAND_ID, openCodexChat), + vscode.commands.registerCommand(REFRESH_CHATS_COMMAND_ID, () => + historyProvider.refresh(), + ), + historyProvider, + historyView, + statusSubscription, + visibilitySubscription, + workspaceSubscription, + watchSessionIndex(sessionIndexPath, () => historyProvider.refresh()), + ); } module.exports = { activate }; diff --git a/package-lock.json b/package-lock.json index 4104fb9..6b34969 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-extras", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-extras", - "version": "1.0.1", + "version": "1.1.0", "license": "MIT", "devDependencies": { "@vscode/vsce": "^3.6.2" diff --git a/package.json b/package.json index d24346f..72f4ffb 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,14 @@ "name": "codex-extras", "displayName": "Codex Extras", "description": "Convenient toolbar actions and workflow enhancements for Codex in Visual Studio Code.", - "version": "1.0.1", + "version": "1.1.0", "publisher": "netroforge", "license": "MIT", "icon": "assets/icon.png", "main": "./extension.js", "activationEvents": [ - "onCommand:codexExtras.newCodexAgent" + "onCommand:codexExtras.newCodexAgent", + "onView:codexExtras.chatHistory" ], "engines": { "vscode": "^1.96.2" @@ -62,6 +63,48 @@ "light": "assets/toolbar-light.svg", "dark": "assets/toolbar-dark.svg" } + }, + { + "command": "codexExtras.newChat", + "title": "New Chat", + "category": "Codex Extras", + "icon": "$(add)" + }, + { + "command": "codexExtras.openChat", + "title": "Open Chat", + "category": "Codex Extras", + "icon": "$(go-to-file)" + }, + { + "command": "codexExtras.refreshChats", + "title": "Refresh Chats", + "category": "Codex Extras", + "icon": "$(refresh)" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "codexExtrasChatsContainer", + "title": "Codex Chats", + "icon": "assets/sidebar.svg" + } + ] + }, + "views": { + "codexExtrasChatsContainer": [ + { + "id": "codexExtras.chatHistory", + "name": "Codex Chats", + "contextualTitle": "Codex Chats" + } + ] + }, + "viewsWelcome": [ + { + "view": "codexExtras.chatHistory", + "contents": "No local Codex chats yet.\n[Start a new chat](command:codexExtras.newChat)" } ], "configuration": { @@ -82,17 +125,40 @@ "when": "config.codexExtras.showNewAgentButton" } ], + "view/title": [ + { + "command": "codexExtras.newChat", + "group": "navigation@1", + "when": "view == codexExtras.chatHistory" + }, + { + "command": "codexExtras.refreshChats", + "group": "navigation@2", + "when": "view == codexExtras.chatHistory" + } + ], + "view/item/context": [ + { + "command": "codexExtras.openChat", + "group": "inline", + "when": "view == codexExtras.chatHistory && viewItem == codexChat" + } + ], "commandPalette": [ { "command": "codexExtras.newCodexAgent", "when": "false" + }, + { + "command": "codexExtras.openChat", + "when": "false" } ] } }, "scripts": { "check": "node scripts/validate.mjs", - "test": "npm run check", + "test": "npm run check && node --test", "package": "vsce package --no-dependencies", "publish:marketplace": "vsce publish --no-dependencies" }, diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 65a4979..3e95090 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -19,6 +19,9 @@ assert.equal(manifest.main, "./extension.js"); assert.ok( manifest.activationEvents.includes("onCommand:codexExtras.newCodexAgent"), ); +assert.ok( + manifest.activationEvents.includes("onView:codexExtras.chatHistory"), +); const newAgentCommand = manifest.contributes?.commands?.find( ({ command }) => command === "codexExtras.newCodexAgent", @@ -52,6 +55,47 @@ assert.equal( true, ); +const chatContainer = + manifest.contributes?.viewsContainers?.activitybar?.find( + ({ id }) => id === "codexExtrasChatsContainer", + ); +assert.ok(chatContainer, "Codex Chats Activity Bar container is missing"); +assert.equal(chatContainer.icon, "assets/sidebar.svg"); + +const chatView = manifest.contributes?.views?.codexExtrasChatsContainer?.find( + ({ id }) => id === "codexExtras.chatHistory", +); +assert.ok(chatView, "Codex chat history view is missing"); + +for (const command of [ + "codexExtras.newChat", + "codexExtras.openChat", + "codexExtras.refreshChats", +]) { + assert.ok( + manifest.contributes.commands.some((item) => item.command === command), + `${command} contribution is missing`, + ); +} + +const viewTitleItems = manifest.contributes?.menus?.["view/title"] ?? []; +assert.ok( + viewTitleItems.some( + ({ command, when }) => + command === "codexExtras.newChat" && + when === "view == codexExtras.chatHistory", + ), + "New Chat view action is missing", +); +assert.ok( + viewTitleItems.some( + ({ command, when }) => + command === "codexExtras.refreshChats" && + when === "view == codexExtras.chatHistory", + ), + "Refresh Chats view action is missing", +); + const commandPaletteItems = manifest.contributes?.menus?.commandPalette ?? []; assert.ok( @@ -76,6 +120,43 @@ assert.match( /executeCommand\(OFFICIAL_CODEX_COMMAND_ID\)/, "Wrapper command must delegate to the official Codex command", ); +assert.match( + extensionSource, + /CODEX_NEW_PANEL_PATH/, + "Wrapper command must support distinct empty Codex panels", +); +assert.match( + extensionSource, + /createTreeView\(CHAT_HISTORY_VIEW_ID/, + "Extension must register the Codex chat history view", +); +assert.match( + extensionSource, + /path: `\/local\/\$\{sessionId\}`/, + "Chat history items must use the official local conversation route", +); +assert.match( + extensionSource, + /"Current Workspace"/, + "Chat history must include a current workspace section", +); +assert.match( + extensionSource, + /"Other Workspaces"/, + "Chat history must separate chats from other workspaces", +); +assert.match( + extensionSource, + /loadSessionWorkspaces/, + "Chat history must load workspace metadata for local sessions", +); + +const sidebarIcon = await readFile( + path.join(root, chatContainer.icon), + "utf8", +); +assert.match(sidebarIcon, /viewBox="0 0 24 24"/); +assert.match(sidebarIcon, /currentColor/); for (const [theme, iconPath] of Object.entries(newAgentCommand.icon)) { const toolbarIcon = await readFile(path.join(root, iconPath), "utf8"); diff --git a/session-index.js b/session-index.js new file mode 100644 index 0000000..b3dc8d9 --- /dev/null +++ b/session-index.js @@ -0,0 +1,176 @@ +"use strict"; + +const { open, readdir } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); + +const SESSION_INDEX_FILENAME = "session_index.jsonl"; +const SESSIONS_DIRECTORY_NAME = "sessions"; +const SESSION_METADATA_PREFIX_BYTES = 64 * 1024; +const SESSION_ID_IN_FILENAME = + /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; + +function getCodexHome(environment = process.env, homeDirectory = os.homedir()) { + const configuredHome = environment.CODEX_HOME?.trim(); + + return configuredHome + ? path.resolve(configuredHome) + : path.join(homeDirectory, ".codex"); +} + +function getSessionIndexPath(codexHome = getCodexHome()) { + return path.join(codexHome, SESSION_INDEX_FILENAME); +} + +function getSessionsDirectory(codexHome = getCodexHome()) { + return path.join(codexHome, SESSIONS_DIRECTORY_NAME); +} + +function parseSessionIndex(contents) { + const sessionsById = new Map(); + + for (const line of contents.split(/\r?\n/)) { + if (!line.trim()) { + continue; + } + + let value; + try { + value = JSON.parse(line); + } catch { + continue; + } + + if ( + typeof value?.id !== "string" || + !/^[a-zA-Z0-9_-]+$/.test(value.id) + ) { + continue; + } + + const updatedAt = + typeof value.updated_at === "string" && + Number.isFinite(Date.parse(value.updated_at)) + ? value.updated_at + : undefined; + const title = + typeof value.thread_name === "string" && value.thread_name.trim() + ? value.thread_name.trim() + : "Untitled chat"; + const existing = sessionsById.get(value.id); + + if ( + !existing || + (updatedAt && + (!existing.updatedAt || + Date.parse(updatedAt) >= Date.parse(existing.updatedAt))) + ) { + sessionsById.set(value.id, { + id: value.id, + title, + updatedAt, + }); + } + } + + return [...sessionsById.values()].sort((left, right) => { + const timeDifference = + (right.updatedAt ? Date.parse(right.updatedAt) : 0) - + (left.updatedAt ? Date.parse(left.updatedAt) : 0); + + return timeDifference || left.title.localeCompare(right.title); + }); +} + +async function readSessionWorkspace(filePath) { + let handle; + + try { + handle = await open(filePath, "r"); + const buffer = Buffer.alloc(SESSION_METADATA_PREFIX_BYTES); + const { bytesRead } = await handle.read( + buffer, + 0, + buffer.length, + 0, + ); + const prefix = buffer.toString("utf8", 0, bytesRead); + const cwdMatch = prefix.match(/"cwd":("(?:\\.|[^"\\])*")/); + + return cwdMatch ? JSON.parse(cwdMatch[1]) : undefined; + } catch { + return undefined; + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function findSessionFiles(directory, requestedSessionIds, results) { + let entries; + + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + + await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + await findSessionFiles(entryPath, requestedSessionIds, results); + return; + } + + const sessionId = entry.name.match(SESSION_ID_IN_FILENAME)?.[1]; + if ( + sessionId && + requestedSessionIds.has(sessionId) && + !results.has(sessionId) + ) { + const workspacePath = await readSessionWorkspace(entryPath); + if (workspacePath) { + results.set(sessionId, workspacePath); + } + } + }), + ); +} + +async function loadSessionWorkspaces(sessionsDirectory, sessionIds) { + const requestedSessionIds = new Set(sessionIds); + const results = new Map(); + + if (requestedSessionIds.size > 0) { + await findSessionFiles(sessionsDirectory, requestedSessionIds, results); + } + + return results; +} + +function isPathInsideWorkspace(candidatePath, workspacePath) { + if (!candidatePath || !workspacePath) { + return false; + } + + const relativePath = path.relative( + path.resolve(workspacePath), + path.resolve(candidatePath), + ); + + return ( + relativePath === "" || + (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) + ); +} + +module.exports = { + SESSION_INDEX_FILENAME, + getCodexHome, + getSessionIndexPath, + getSessionsDirectory, + isPathInsideWorkspace, + loadSessionWorkspaces, + parseSessionIndex, +}; diff --git a/test/extension.test.cjs b/test/extension.test.cjs new file mode 100644 index 0000000..24202a6 --- /dev/null +++ b/test/extension.test.cjs @@ -0,0 +1,347 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { mkdtemp, mkdir, rm, writeFile } = require("node:fs/promises"); +const Module = require("node:module"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const extensionPath = path.resolve(__dirname, "..", "extension.js"); +const originalLoad = Module._load; + +function loadExtension(vscode) { + delete require.cache[extensionPath]; + Module._load = function load(request, parent, isMain) { + if (request === "vscode") { + return vscode; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + try { + return require(extensionPath); + } finally { + Module._load = originalLoad; + } +} + +function createHarness(t, tabs = [], options = {}) { + const calls = []; + const commandHandlers = new Map(); + const disposable = { dispose() {} }; + const treeViews = []; + + class EventEmitter { + constructor() { + this.listeners = new Set(); + this.event = (listener) => { + this.listeners.add(listener); + return { + dispose: () => this.listeners.delete(listener), + }; + }; + } + + fire(value) { + for (const listener of this.listeners) { + listener(value); + } + } + + dispose() { + this.listeners.clear(); + } + } + + class TreeItem { + constructor(label, collapsibleState) { + this.label = label; + this.collapsibleState = collapsibleState; + } + } + + const vscode = { + commands: { + registerCommand(command, handler) { + commandHandlers.set(command, handler); + return disposable; + }, + async executeCommand(...args) { + calls.push(args); + }, + }, + Uri: { + from(value) { + return { ...value }; + }, + }, + ViewColumn: { + Active: -1, + }, + EventEmitter, + ThemeIcon: class ThemeIcon { + constructor(id) { + this.id = id; + } + }, + TreeItem, + TreeItemCollapsibleState: { + None: 0, + Collapsed: 1, + Expanded: 2, + }, + workspace: { + workspaceFolders: options.workspaceFolders ?? [ + { + name: "codex-extras", + uri: { + fsPath: "/home/person/codex-extras", + }, + }, + ], + onDidChangeWorkspaceFolders() { + return disposable; + }, + }, + window: { + activeTextEditor: undefined, + tabGroups: { + all: [{ tabs }], + }, + createTreeView(id, options) { + const view = { + id, + options, + dispose() {}, + onDidChangeVisibility() { + return disposable; + }, + }; + treeViews.push(view); + return view; + }, + }, + }; + const context = { subscriptions: [] }; + const originalCodexHome = process.env.CODEX_HOME; + + if (options.codexHome) { + process.env.CODEX_HOME = options.codexHome; + } + + try { + loadExtension(vscode).activate(context); + } finally { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + } + + t.after(() => { + for (const subscription of context.subscriptions) { + subscription.dispose(); + } + }); + + return { + calls, + commandHandlers, + treeViews, + runCommand: (command = "codexExtras.newCodexAgent", ...args) => + commandHandlers.get(command)(...args), + }; +} + +function emptyCodexTab(query = "") { + return { + input: { + uri: { + scheme: "openai-codex", + authority: "route", + path: "/extension/panel/new", + query, + }, + }, + }; +} + +test("delegates the first panel to the official Codex command", async (t) => { + const harness = createHarness(t); + + await harness.runCommand(); + + assert.deepEqual(harness.calls, [["chatgpt.newCodexPanel"]]); +}); + +test("opens a distinct Codex route when an empty panel already exists", async (t) => { + const harness = createHarness(t, [emptyCodexTab()]); + + await harness.runCommand(); + + assert.equal(harness.calls.length, 1); + const [command, uri, viewType, options] = harness.calls[0]; + assert.equal(command, "vscode.openWith"); + assert.equal(uri.scheme, "openai-codex"); + assert.equal(uri.authority, "route"); + assert.equal(uri.path, "/extension/panel/new"); + assert.match(uri.query, /^codexExtras=\d+-\d+$/); + assert.equal(viewType, "chatgpt.conversationEditor"); + assert.deepEqual(options, { + viewColumn: -1, + preserveFocus: false, + preview: false, + }); +}); + +test("uses a unique URI for every additional empty panel", async (t) => { + const harness = createHarness(t, [emptyCodexTab()]); + + await harness.runCommand(); + await harness.runCommand(); + + assert.notEqual(harness.calls[0][1].query, harness.calls[1][1].query); +}); + +test("does not treat an existing conversation as an empty panel", async (t) => { + const conversationTab = { + input: { + uri: { + scheme: "openai-codex", + authority: "route", + path: "/local/conversation-id", + }, + }, + }; + const harness = createHarness(t, [conversationTab]); + + await harness.runCommand(); + + assert.deepEqual(harness.calls, [["chatgpt.newCodexPanel"]]); +}); + +test("registers the Codex chat history tree view", (t) => { + const harness = createHarness(t); + + assert.equal(harness.treeViews.length, 1); + assert.equal(harness.treeViews[0].id, "codexExtras.chatHistory"); + assert.equal( + typeof harness.treeViews[0].options.treeDataProvider.getChildren, + "function", + ); + assert.ok(harness.commandHandlers.has("codexExtras.newChat")); + assert.ok(harness.commandHandlers.has("codexExtras.openChat")); + assert.ok(harness.commandHandlers.has("codexExtras.refreshChats")); +}); + +test("opens a selected Codex chat as a pinned editor tab", async (t) => { + const harness = createHarness(t); + const sessionId = "019f9c23-f7ec-72c3-8eb6-0018b17bea4f"; + + await harness.runCommand("codexExtras.openChat", sessionId); + + assert.deepEqual(harness.calls, [ + [ + "vscode.openWith", + { + scheme: "openai-codex", + authority: "route", + path: `/local/${sessionId}`, + }, + "chatgpt.conversationEditor", + { + viewColumn: -1, + preserveFocus: false, + preview: false, + }, + ], + ]); +}); + +test("opens a chat from its inline tree action", async (t) => { + const harness = createHarness(t); + + await harness.runCommand("codexExtras.openChat", { + id: "session-from-tree", + }); + + assert.equal(harness.calls[0][1].path, "/local/session-from-tree"); +}); + +test("groups current workspace chats before other workspaces", async (t) => { + const codexHome = await mkdtemp( + path.join(os.tmpdir(), "codex-extras-extension-test-"), + ); + t.after(() => rm(codexHome, { recursive: true, force: true })); + + const sessionsDirectory = path.join( + codexHome, + "sessions", + "2026", + "07", + "26", + ); + await mkdir(sessionsDirectory, { recursive: true }); + + const currentId = "019f9c23-f7ec-72c3-8eb6-0018b17bea4f"; + const otherId = "019f9c28-a20f-70f3-9df9-518aaf67a20b"; + await writeFile( + path.join(codexHome, "session_index.jsonl"), + [ + JSON.stringify({ + id: currentId, + thread_name: "Current chat", + updated_at: "2026-07-26T05:00:00Z", + }), + JSON.stringify({ + id: otherId, + thread_name: "Other chat", + updated_at: "2026-07-26T04:00:00Z", + }), + ].join("\n"), + ); + + for (const [sessionId, cwd] of [ + [currentId, "/work/current/packages/app"], + [otherId, "/work/other"], + ]) { + await writeFile( + path.join( + sessionsDirectory, + `rollout-2026-07-26T05-00-00-${sessionId}.jsonl`, + ), + `${JSON.stringify({ + type: "session_meta", + payload: { id: sessionId, cwd }, + })}\n`, + ); + } + + const harness = createHarness(t, [], { + codexHome, + workspaceFolders: [ + { + name: "current", + uri: { fsPath: "/work/current" }, + }, + ], + }); + const provider = harness.treeViews[0].options.treeDataProvider; + const groups = await provider.getChildren(); + const currentChats = await provider.getChildren(groups[0]); + const otherChats = await provider.getChildren(groups[1]); + + assert.deepEqual( + groups.map(({ label, collapsibleState }) => [label, collapsibleState]), + [ + ["Current Workspace", 2], + ["Other Workspaces", 1], + ], + ); + assert.equal(currentChats[0].label, "Current chat"); + assert.equal(otherChats[0].label, "Other chat"); + assert.match(otherChats[0].description, /^other · /); +}); diff --git a/test/session-index.test.cjs b/test/session-index.test.cjs new file mode 100644 index 0000000..af7942a --- /dev/null +++ b/test/session-index.test.cjs @@ -0,0 +1,165 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { mkdtemp, mkdir, rm, writeFile } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { + getCodexHome, + getSessionIndexPath, + getSessionsDirectory, + isPathInsideWorkspace, + loadSessionWorkspaces, + parseSessionIndex, +} = require("../session-index"); + +test("uses CODEX_HOME when it is configured", () => { + assert.equal( + getCodexHome({ CODEX_HOME: "/tmp/custom-codex" }, "/home/person"), + path.resolve("/tmp/custom-codex"), + ); + assert.equal( + getSessionIndexPath("/tmp/custom-codex"), + path.join("/tmp/custom-codex", "session_index.jsonl"), + ); + assert.equal( + getSessionsDirectory("/tmp/custom-codex"), + path.join("/tmp/custom-codex", "sessions"), + ); +}); + +test("falls back to the default Codex home", () => { + assert.equal( + getCodexHome({}, "/home/person"), + path.join("/home/person", ".codex"), + ); +}); + +test("parses, deduplicates, and sorts Codex sessions", () => { + const sessions = parseSessionIndex( + [ + JSON.stringify({ + id: "older", + thread_name: "Older title", + updated_at: "2026-07-20T10:00:00Z", + }), + "not JSON", + JSON.stringify({ + id: "newer", + thread_name: " Newer title ", + updated_at: "2026-07-21T10:00:00Z", + }), + JSON.stringify({ + id: "older", + thread_name: "Updated older title", + updated_at: "2026-07-22T10:00:00Z", + }), + JSON.stringify({ + id: "../../invalid", + thread_name: "Invalid ID", + }), + ].join("\n"), + ); + + assert.deepEqual(sessions, [ + { + id: "older", + title: "Updated older title", + updatedAt: "2026-07-22T10:00:00Z", + }, + { + id: "newer", + title: "Newer title", + updatedAt: "2026-07-21T10:00:00Z", + }, + ]); +}); + +test("uses a readable fallback for missing titles and dates", () => { + assert.deepEqual( + parseSessionIndex( + `${JSON.stringify({ id: "session_one", thread_name: " " })}\n`, + ), + [ + { + id: "session_one", + title: "Untitled chat", + updatedAt: undefined, + }, + ], + ); +}); + +test("recognizes chats within the current workspace", () => { + assert.equal( + isPathInsideWorkspace( + "/home/person/project/packages/app", + "/home/person/project", + ), + true, + ); + assert.equal( + isPathInsideWorkspace( + "/home/person/project-other", + "/home/person/project", + ), + false, + ); +}); + +test("loads only the workspace metadata prefix for requested sessions", async (t) => { + const temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), "codex-extras-test-"), + ); + t.after(() => rm(temporaryDirectory, { recursive: true, force: true })); + + const sessionsDirectory = path.join( + temporaryDirectory, + "sessions", + "2026", + "07", + "26", + ); + await mkdir(sessionsDirectory, { recursive: true }); + + const requestedId = "019f9c23-f7ec-72c3-8eb6-0018b17bea4f"; + const ignoredId = "019f9c28-a20f-70f3-9df9-518aaf67a20b"; + await writeFile( + path.join( + sessionsDirectory, + `rollout-2026-07-26T04-57-10-${requestedId}.jsonl`, + ), + `${JSON.stringify({ + type: "session_meta", + payload: { + id: requestedId, + cwd: "/home/person/current-project", + }, + })}\n{"large":"transcript body is not needed"}\n`, + ); + await writeFile( + path.join( + sessionsDirectory, + `rollout-2026-07-26T05-00-00-${ignoredId}.jsonl`, + ), + `${JSON.stringify({ + type: "session_meta", + payload: { + id: ignoredId, + cwd: "/home/person/other-project", + }, + })}\n`, + ); + + const workspaces = await loadSessionWorkspaces( + path.join(temporaryDirectory, "sessions"), + [requestedId], + ); + + assert.deepEqual( + [...workspaces], + [[requestedId, "/home/person/current-project"]], + ); +});