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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to Codex Extras are documented in this file.

## Unreleased

- Add opt-in latest-result sorting within workspace groups. Only completed
results change recency; opening, focusing, or reading a chat cannot move it.
Remember observed completion times across reloads; keep index-date sorting
as the default.

## 1.1.0

- Add a Codex Chats Activity Bar panel with local chat history, automatic
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,23 @@ The official Codex extension is declared as a dependency and is installed automa

| Setting | Default | Description |
| --- | --- | --- |
| `codexExtras.chatSortOrder` | `recent` | `recent`: session-index date; `latestResult`: most recently delivered result first, within each workspace group. |
| `codexExtras.showNewAgentButton` | `true` | Shows the New Codex Agent button in the editor toolbar. |

Set **Codex Extras: Chat Sort Order** to **latestResult** to order each workspace
by the time its sessions last delivered a completed result. Opening a chat,
changing focus, reading a result, starting work, questions, and streamed tokens
do not change the order. A new completed result moves that chat up within its
workspace group. Remembered completion times survive reloads. Chats with no known
result time appear below dated results in a stable order. Switching back to
**recent** restores the original index-date ordering immediately.
This option reads bounded log tails, without changing conversation icons; it does not
require a live IPC connection.
Only explicit `task_complete` records with valid timestamps count. Reads are
cached and limited to the last 256 KiB per log. An older completion outside that
tail can be unknown on first use; once observed, its time is remembered locally.
No transcripts are modified. File events refresh results without status polling.

## Install

Install **Codex Extras** from the Visual Studio Marketplace, or use:
Expand Down
66 changes: 62 additions & 4 deletions extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ const fs = require("node:fs");
const { readFile } = require("node:fs/promises");
const path = require("node:path");
const vscode = require("vscode");
const { SessionResultReader, watchSessionResults } = require("./session-results");
const { sortSessions } = require("./session-sort");
const {
getSessionIndexPath,
getSessionsDirectory,
isPathInsideWorkspace,
loadSessionFiles,
loadSessionWorkspaces,
parseSessionIndex,
} = require("./session-index");
Expand All @@ -23,6 +26,7 @@ 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";
const RESULT_TIMES_KEY = "codexExtras.lastResultTimes";

let panelSequence = 0;
let openQueue = Promise.resolve();
Expand Down Expand Up @@ -77,10 +81,14 @@ class CodexChatGroupItem extends vscode.TreeItem {
}

class CodexChatHistoryProvider {
constructor(sessionIndexPath, sessionsDirectory, getWorkspaceFolders) {
constructor(sessionIndexPath, sessionsDirectory, getWorkspaceFolders, getResultTimes, saveResultTimes) {
this.sessionIndexPath = sessionIndexPath;
this.sessionsDirectory = sessionsDirectory;
this.getWorkspaceFolders = getWorkspaceFolders;
this.getResultTimes = getResultTimes;
this.saveResultTimes = saveResultTimes;
this.sortOrder = "recent";
this.resultReader = new SessionResultReader();
this.workspaceBySessionId = new Map();
this.changeEmitter = new vscode.EventEmitter();
this.statusEmitter = new vscode.EventEmitter();
Expand Down Expand Up @@ -111,23 +119,33 @@ class CodexChatHistoryProvider {
const missingWorkspaceIds = sessions
.filter((session) => !this.workspaceBySessionId.has(session.id))
.map((session) => session.id);
const files = this.sortOrder === "latestResult"
? await loadSessionFiles(this.sessionsDirectory, sessions.map(({ id }) => id)) : undefined;
const loadedWorkspaces = await loadSessionWorkspaces(
this.sessionsDirectory,
missingWorkspaceIds,
files,
);

for (const [sessionId, workspacePath] of loadedWorkspaces) {
this.workspaceBySessionId.set(sessionId, workspacePath);
}

const workspaceFolders = this.getWorkspaceFolders();
const enrichedSessions = sessions.map((session) => ({
const enrichedSessions = await Promise.all(sessions.map(async (session) => ({
...session,
workspacePath: this.workspaceBySessionId.get(session.id),
}));
completedAt: files ? Math.max(this.getResultTimes()[session.id] ?? 0,
files.has(session.id) ? await this.resultReader.read(files.get(session.id)) : 0) : 0,
})));
if (files) {
this.resultReader.retain(files.values());
await this.saveResultTimes(Object.fromEntries(enrichedSessions
.filter((session) => session.completedAt > 0).map((session) => [session.id, session.completedAt])));
}

this.statusEmitter.fire(undefined);
return createChatGroups(enrichedSessions, workspaceFolders);
return createChatGroups(sortSessions(enrichedSessions, this.sortOrder), workspaceFolders);
} catch (error) {
if (error?.code === "ENOENT") {
this.statusEmitter.fire(undefined);
Expand All @@ -140,6 +158,7 @@ class CodexChatHistoryProvider {
}

dispose() {
this.resultReader.clear();
this.changeEmitter.dispose();
this.statusEmitter.dispose();
}
Expand Down Expand Up @@ -350,6 +369,8 @@ function watchSessionIndex(sessionIndexPath, onChange) {
* @param {import("vscode").ExtensionContext} context
*/
function activate(context) {
let resultTimes = context.globalState.get(RESULT_TIMES_KEY, {});
let resultTimesQueue = Promise.resolve();
const openNewChat = () => {
openQueue = openQueue.catch(() => undefined).then(openNewCodexAgent);
return openQueue;
Expand All @@ -359,6 +380,24 @@ function activate(context) {
sessionIndexPath,
getSessionsDirectory(),
() => vscode.workspace.workspaceFolders ?? [],
() => resultTimes,
(times) => {
resultTimesQueue = resultTimesQueue.catch(() => {}).then(async () => {
const next = { ...resultTimes };
let changed = false;
for (const [id, time] of Object.entries(times)) {
if (Number.isFinite(time) && time > (next[id] ?? 0)) {
next[id] = time;
changed = true;
}
}
if (changed) {
await context.globalState.update(RESULT_TIMES_KEY, next);
resultTimes = next;
}
});
return resultTimesQueue;
},
);
const historyView = vscode.window.createTreeView(CHAT_HISTORY_VIEW_ID, {
treeDataProvider: historyProvider,
Expand All @@ -377,6 +416,23 @@ function activate(context) {
const workspaceSubscription = vscode.workspace.onDidChangeWorkspaceFolders(
() => historyProvider.refresh(),
);
let resultWatcher;
const updateSortOrder = () => {
const order = vscode.workspace.getConfiguration("codexExtras").get("chatSortOrder", "recent");
historyProvider.sortOrder = order === "latestResult" ? order : "recent";
if (historyProvider.sortOrder === "latestResult" && !resultWatcher) {
resultWatcher = watchSessionResults(historyProvider.sessionsDirectory, () => historyProvider.refresh(), vscode);
} else if (historyProvider.sortOrder === "recent") {
resultWatcher?.dispose();
resultWatcher = undefined;
historyProvider.resultReader.clear();
}
historyProvider.refresh();
};
const configurationSubscription = vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration("codexExtras.chatSortOrder")) updateSortOrder();
});
updateSortOrder();

context.subscriptions.push(
vscode.commands.registerCommand(COMMAND_ID, openNewChat),
Expand All @@ -390,6 +446,8 @@ function activate(context) {
statusSubscription,
visibilitySubscription,
workspaceSubscription,
configurationSubscription,
{ dispose() { resultWatcher?.dispose(); } },
watchSessionIndex(sessionIndexPath, () => historyProvider.refresh()),
);
}
Expand Down
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@
"type": "boolean",
"default": true,
"description": "Show the New Codex Agent button in the editor toolbar."
},
"codexExtras.chatSortOrder": {
"type": "string",
"enum": [
"recent",
"latestResult"
],
"enumDescriptions": [
"Newest session-index update first.",
"Most recently delivered result first. Opening, focus, read state, and live activity do not change the order."
],
"default": "recent",
"description": "Sort chats within each workspace group. Latest result uses bounded log reads and locally remembered completion times, independently of status icons."
}
}
},
Expand Down
2 changes: 2 additions & 0 deletions scripts/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ assert.equal(manifest.license, "MIT");
assert.equal(manifest.repository.url, "https://github.com/netroforge/codex-extras.git");
assert.ok(manifest.extensionDependencies.includes("openai.chatgpt"));
assert.equal(manifest.main, "./extension.js");
assert.equal(manifest.contributes.configuration.properties["codexExtras.chatSortOrder"].default, "recent");
assert.deepEqual(manifest.contributes.configuration.properties["codexExtras.chatSortOrder"].enum, ["recent", "latestResult"]);
assert.ok(
manifest.activationEvents.includes("onCommand:codexExtras.newCodexAgent"),
);
Expand Down
20 changes: 15 additions & 5 deletions session-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,16 +129,13 @@ async function findSessionFiles(directory, requestedSessionIds, results) {
requestedSessionIds.has(sessionId) &&
!results.has(sessionId)
) {
const workspacePath = await readSessionWorkspace(entryPath);
if (workspacePath) {
results.set(sessionId, workspacePath);
}
results.set(sessionId, entryPath);
}
}),
);
}

async function loadSessionWorkspaces(sessionsDirectory, sessionIds) {
async function loadSessionFiles(sessionsDirectory, sessionIds) {
const requestedSessionIds = new Set(sessionIds);
const results = new Map();

Expand All @@ -149,6 +146,18 @@ async function loadSessionWorkspaces(sessionsDirectory, sessionIds) {
return results;
}

async function loadSessionWorkspaces(sessionsDirectory, sessionIds, sessionFiles) {
const files = sessionFiles ?? await loadSessionFiles(sessionsDirectory, sessionIds);
const results = new Map();
await Promise.all(sessionIds.map(async (id) => {
const filePath = files.get(id);
if (!filePath) return;
const workspacePath = await readSessionWorkspace(filePath);
if (workspacePath) results.set(id, workspacePath);
}));
return results;
}

function isPathInsideWorkspace(candidatePath, workspacePath) {
if (!candidatePath || !workspacePath) {
return false;
Expand All @@ -171,6 +180,7 @@ module.exports = {
getSessionIndexPath,
getSessionsDirectory,
isPathInsideWorkspace,
loadSessionFiles,
loadSessionWorkspaces,
parseSessionIndex,
};
71 changes: 71 additions & 0 deletions session-results.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use strict";

const { open } = require("node:fs/promises");
const RESULT_TAIL_BYTES = 256 * 1024;

function parseResultTime(contents, previous = 0) {
let result = previous;
for (const line of contents.split("\n").slice(0, -1)) {
try {
const event = JSON.parse(line);
if (event.type !== "event_msg" || event.payload?.type !== "task_complete") continue;
const time = typeof event.timestamp === "string" ? Date.parse(event.timestamp) : NaN;
if (Number.isFinite(time) && time > result) result = time;
} catch {
// Ignore partial or malformed records; never infer completion from text.
}
}
return result;
}

class SessionResultReader {
constructor() { this.cache = new Map(); }

async read(filePath) {
let handle;
try {
handle = await open(filePath, "r");
const stat = await handle.stat();
const previous = this.cache.get(filePath);
if (previous?.size === stat.size && previous.mtimeMs === stat.mtimeMs && previous.ino === stat.ino) return previous.time;
const growing = previous && previous.ino === stat.ino && stat.size > previous.size;
const start = Math.max(0, stat.size - RESULT_TAIL_BYTES);
const buffer = Buffer.alloc(stat.size - start);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
const firstLineEnd = start > 0 ? buffer.indexOf(10) : -1;
const skip = start > 0 ? (firstLineEnd < 0 ? bytesRead : firstLineEnd + 1) : 0;
const time = parseResultTime(buffer.toString("utf8", skip, bytesRead), growing ? previous.time : 0);
this.cache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino, time });
return time;
} catch {
this.cache.delete(filePath);
return 0;
} finally {
await handle?.close().catch(() => {});
}
}

retain(filePaths) {
const retained = new Set(filePaths);
for (const filePath of this.cache.keys()) if (!retained.has(filePath)) this.cache.delete(filePath);
}

clear() { this.cache.clear(); }
}

function watchSessionResults(directory, onChange, vscode) {
const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(directory, "**/*.jsonl"));
let timer;
const refresh = () => {
clearTimeout(timer);
timer = setTimeout(onChange, 100);
};
const subscriptions = [watcher.onDidCreate(refresh), watcher.onDidChange(refresh), watcher.onDidDelete(refresh)];
return { dispose() {
clearTimeout(timer);
for (const subscription of subscriptions) subscription.dispose();
watcher.dispose();
} };
}

module.exports = { SessionResultReader, parseResultTime, watchSessionResults };
10 changes: 10 additions & 0 deletions session-sort.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use strict";

function sortSessions(sessions, order) {
if (order !== "latestResult") return sessions;
return [...sessions].sort((left, right) => {
return (right.completedAt ?? 0) - (left.completedAt ?? 0) || right.id.localeCompare(left.id);
});
}

module.exports = { sortSessions };
Loading