diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProjectConversations.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProjectConversations.test.tsx
new file mode 100644
index 00000000000..96edcf268ff
--- /dev/null
+++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProjectConversations.test.tsx
@@ -0,0 +1,50 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import ProjectConversations, {
+ CONVERSATION_INITIAL_ROWS,
+ CONVERSATION_MAX_ROWS,
+} from "./ProjectConversations";
+import type { CodexWorkspacesSessionUsage } from "../../../types/bridge";
+import type { LocaleKey } from "../../../i18n/keys";
+
+const t = (key: LocaleKey) => key;
+
+function session(index: number, unknownTokens = 0): CodexWorkspacesSessionUsage {
+ return {
+ id: `session-${index}`,
+ projectId: "project-a",
+ displayTitle: `Thread ${index}`,
+ cwd: null,
+ startedAt: null,
+ latestActivity: null,
+ totals: { inputTokens: 10, cachedInputTokens: 0, outputTokens: 0, totalTokens: 10 },
+ costEstimate: { knownUsd: 100 - index, unknownTokens },
+ topModel: null,
+ };
+}
+
+describe("ProjectConversations", () => {
+ it("shows ranked rows in the given order with their rank", () => {
+ render();
+
+ expect(screen.getByText("#1")).toBeTruthy();
+ expect(screen.getByText("#2")).toBeTruthy();
+ expect(screen.getByText("Thread 1").getAttribute("title")).toBe("Thread 1");
+ expect(screen.getByText(/\$99\.00/)).toBeTruthy();
+ expect(screen.getByText(/~\$98\.00/)).toBeTruthy();
+ expect(screen.queryByRole("button")).toBeNull();
+ });
+
+ it("shows the top rows first and expands up to the display limit", () => {
+ const conversations = Array.from({ length: CONVERSATION_MAX_ROWS + 5 }, (_, i) => session(i + 1));
+ render();
+
+ expect(screen.getAllByText(/^#\d+$/)).toHaveLength(CONVERSATION_INITIAL_ROWS);
+ fireEvent.click(screen.getByRole("button", { name: `UsageSpendShowAll (${CONVERSATION_MAX_ROWS})` }));
+ expect(screen.getAllByText(/^#\d+$/)).toHaveLength(CONVERSATION_MAX_ROWS);
+ expect(screen.queryByText(`Thread ${CONVERSATION_MAX_ROWS + 1}`)).toBeNull();
+
+ fireEvent.click(screen.getByRole("button", { name: "UsageSpendShowLess" }));
+ expect(screen.getAllByText(/^#\d+$/)).toHaveLength(CONVERSATION_INITIAL_ROWS);
+ });
+});
diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProjectConversations.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProjectConversations.tsx
new file mode 100644
index 00000000000..948d1b0bc4d
--- /dev/null
+++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProjectConversations.tsx
@@ -0,0 +1,55 @@
+import { useState } from "react";
+import type { LocaleKey } from "../../../i18n/keys";
+import type { CodexWorkspacesSessionUsage } from "../../../types/bridge";
+
+/** Conversations shown before the expand control, and the most ever shown. */
+export const CONVERSATION_INITIAL_ROWS = 8;
+export const CONVERSATION_MAX_ROWS = 50;
+
+interface ProjectConversationsProps {
+ /** Already ranked by the backend: cost descending, unpriced last. */
+ conversations: CodexWorkspacesSessionUsage[];
+ t: (key: LocaleKey) => string;
+}
+
+export default function ProjectConversations({ conversations, t }: ProjectConversationsProps) {
+ const [showAll, setShowAll] = useState(false);
+ const capped = conversations.slice(0, CONVERSATION_MAX_ROWS);
+ const visible = showAll ? capped : capped.slice(0, CONVERSATION_INITIAL_ROWS);
+
+ return (
+
+ {visible.map((session, index) => (
+
+
+ #{index + 1}
+
+
+ {session.displayTitle}
+
+
+ {session.costEstimate.unknownTokens > 0 ? "~" : ""}
+ ${session.costEstimate.knownUsd.toFixed(2)}
+
+
+ ))}
+ {capped.length > CONVERSATION_INITIAL_ROWS && (
+
+ )}
+
+ );
+}
diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx
index 0f7cc7ee899..ed0fcc90051 100644
--- a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx
+++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx
@@ -16,6 +16,7 @@ import {
import type { CostSummaryDisplayStyle, SettingsSnapshot, SpendContract, UsageSpendSummary } from "../../../types/bridge";
import type { LocaleKey } from "../../../i18n/keys";
import type { TabProps } from "../settingsTabs";
+import ProjectConversations from "./ProjectConversations";
export default function UsageSpendTab(_props: TabProps) {
const { t } = useLocale();
@@ -471,24 +472,10 @@ function ProjectsPanel({
{isExpanded && (
-
- {contract.conversations
- .filter((session) => session.projectId === project.id)
- .map((session) => (
-
-
- {session.displayTitle}
-
-
- {session.costEstimate.unknownTokens > 0 ? "~" : ""}
- ${session.costEstimate.knownUsd.toFixed(2)}
-
-
- ))}
-
+ session.projectId === project.id)}
+ t={t}
+ />
)}
);
diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs
index 8e9be16dc27..297c12b6095 100755
--- a/rust/src/cli/cost.rs
+++ b/rust/src/cli/cost.rs
@@ -8,6 +8,7 @@ use super::usage::{OutputFormat, ProviderSelection};
use crate::codex_costs::{
CodexHostCostReport, CodexHostCostsArgs, CodexHostOutcome, run_codex_host_costs,
};
+use crate::codex_workspaces::short_session_id;
use crate::core::{CostScanOptions, ProviderId};
use crate::cost_scanner::{CostScanner, CostSummary};
use crate::settings::Settings;
@@ -436,23 +437,6 @@ fn print_codex_session_output(result: &CostResult, days: u32) {
println!(" Not a subscription bill or plan value · local usage × public API prices");
}
-fn short_session_id(value: &str) -> String {
- let trimmed = value.trim();
- if trimmed.chars().count() <= 12 {
- return trimmed.to_string();
- }
- let prefix: String = trimmed.chars().take(4).collect();
- let suffix: String = trimmed
- .chars()
- .rev()
- .take(8)
- .collect::>()
- .into_iter()
- .rev()
- .collect();
- format!("{prefix}...{suffix}")
-}
-
/// Print JSON output
fn build_json_payloads(results: &[CostResult], days: u32) -> Vec {
let settings = Settings::load();
@@ -664,12 +648,6 @@ mod tests {
assert_eq!(CostGroupBy::from_arg(Some("session")), CostGroupBy::Session);
}
- #[test]
- fn short_session_id_is_privacy_conscious() {
- assert_eq!(short_session_id("abc"), "abc");
- assert_eq!(short_session_id("1234567890abcdef"), "1234...90abcdef");
- }
-
#[test]
fn remote_failure_retains_local_report() {
let local_summary = CodexCostSummary::from_summaries_at(
diff --git a/rust/src/codex_workspaces/indexer.rs b/rust/src/codex_workspaces/indexer.rs
index 993b695412b..6af5662f75e 100644
--- a/rust/src/codex_workspaces/indexer.rs
+++ b/rust/src/codex_workspaces/indexer.rs
@@ -12,9 +12,10 @@ use crate::codex_costs::codex_period_start;
use crate::core::{CostUsageDayRange, CostUsagePricing, JsonlScanner, sha256_hex};
use super::sidecar::{SidecarError, WorkspaceUsageSidecar};
+use super::thread_names::apply_session_names_and_ranking;
use super::types::{
CodexLocalProjectUsageSnapshot, CostEstimate, DailyPoint, Progress, ProgressPhase,
- ProjectUsage, SessionUsage, SourceStatus, UsageTotals,
+ ProjectUsage, SessionUsage, SourceStatus, UsageTotals, untitled_session_label,
};
use super::{CHATS_DISPLAY_NAME, CHATS_PROJECT_ID};
@@ -39,10 +40,9 @@ pub struct CodexLocalDataScope {
}
impl CodexLocalDataScope {
- /// `CODEX_HOME` → `CODEX_SQLITE_HOME` → `~/.codex`.
+ /// `CODEX_HOME` → `~/.codex`; the SQLite override is only for thread metadata.
pub fn resolve() -> Option {
let home = non_empty_env("CODEX_HOME")
- .or_else(|| non_empty_env("CODEX_SQLITE_HOME"))
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".codex")))?;
Some(Self::from_home(home))
@@ -148,6 +148,11 @@ impl CodexWorkspacesIndex {
match sidecar.load_latest_snapshot(scope.scope_signature(), self.history_days) {
Ok(Some(mut cached)) => {
cached.source_status = source_status;
+ apply_session_names_and_ranking(
+ &mut cached,
+ &scope.codex_home,
+ non_empty_env("CODEX_SQLITE_HOME").as_deref(),
+ );
return Ok(cached);
}
Ok(None) => {}
@@ -241,17 +246,12 @@ impl CodexWorkspacesIndex {
.collect();
daily.sort_by(|a, b| a.day.cmp(&b.day));
- let mut sessions: Vec = session_buckets
+ let sessions: Vec = session_buckets
.values()
.map(SessionBucket::to_session_usage)
.collect();
- sessions.sort_by(|a, b| {
- b.latest_activity
- .cmp(&a.latest_activity)
- .then_with(|| a.id.cmp(&b.id))
- });
- let snapshot = CodexLocalProjectUsageSnapshot {
+ let mut snapshot = CodexLocalProjectUsageSnapshot {
updated_at: Utc::now(),
history_days: self.history_days,
scope_signature: scope.scope_signature().to_string(),
@@ -263,6 +263,11 @@ impl CodexWorkspacesIndex {
daily,
source_status,
};
+ apply_session_names_and_ranking(
+ &mut snapshot,
+ &scope.codex_home,
+ non_empty_env("CODEX_SQLITE_HOME").as_deref(),
+ );
progress(Progress::phase(ProgressPhase::Saving));
sidecar.publish_snapshot(&snapshot)?;
@@ -359,7 +364,7 @@ impl SessionBucket {
.title
.clone()
.filter(|t| !t.trim().is_empty())
- .unwrap_or_else(|| "Local Codex chat".to_string()),
+ .unwrap_or_else(|| untitled_session_label(&self.id)),
cwd: self.cwd.clone(),
started_at: self.started_at,
latest_activity: self.latest_activity,
@@ -412,11 +417,6 @@ fn build_projects(sessions: &HashMap) -> Vec) -> Vec = day.split('-').collect();
+ let folder = home
+ .join("sessions")
+ .join(parts[0])
+ .join(parts[1])
+ .join(parts[2]);
+ fs::create_dir_all(&folder).unwrap();
+ let mut file = File::create(folder.join(format!("{id}.jsonl"))).unwrap();
+ writeln!(
+ file,
+ r#"{{"timestamp":"{day}T10:00:00.000Z","type":"session_meta","payload":{{"session_id":"{id}","cwd":"{cwd}","originator":"codex_exec","source":"cli"}}}}"#,
+ cwd = cwd.to_string_lossy().replace('\\', "\\\\"),
+ )
+ .unwrap();
+ writeln!(
+ file,
+ r#"{{"timestamp":"{day}T10:00:01.000Z","type":"turn_context","payload":{{"model":"gpt-5"}}}}"#
+ )
+ .unwrap();
+ writeln!(
+ file,
+ r#"{{"timestamp":"{day}T10:00:02.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"last_token_usage":{{"input_tokens":{input},"cached_input_tokens":0,"output_tokens":{output}}}}}}}}}"#
+ )
+ .unwrap();
+}
+
+/// Three sessions in one project; `cheap` < `middle` < `dear` by cost.
+fn build_home(root: &Path, named: bool) -> CodexLocalProjectUsageSnapshot {
+ let home = root.join("codex");
+ let project = root.join("project");
+ fs::create_dir_all(&project).unwrap();
+ let short = "short-id";
+ let long = "019f79b9-1790-7921-8d6f-258a1e92b191";
+ write_session(&home, short, &project, 1_000, 100);
+ write_session(&home, "middle-session", &project, 20_000, 1_000);
+ write_session(&home, long, &project, 900_000, 90_000);
+
+ if named {
+ // Pin the SQLite home so a developer's CODEX_SQLITE_HOME cannot interfere.
+ fs::write(
+ home.join("config.toml"),
+ format!(
+ "sqlite_home = '{}'\n",
+ home.canonicalize().unwrap().display()
+ ),
+ )
+ .unwrap();
+ fs::write(
+ home.join("session_index.jsonl"),
+ "{\"id\":\"middle-session\",\"thread_name\":\"Fix the icon\"}\n",
+ )
+ .unwrap();
+ let conn = Connection::open(home.join("state_5.sqlite")).unwrap();
+ conn.execute_batch(
+ "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT);
+ INSERT INTO threads (id, title) VALUES ('short-id', 'Database title');
+ INSERT INTO threads (id, title) VALUES ('middle-session', 'Ignored, index wins');",
+ )
+ .unwrap();
+ }
+
+ CodexWorkspacesIndex::new(30)
+ .with_codex_home(&home)
+ .with_sidecar_path(root.join("sidecar.sqlite"))
+ .load_snapshot(true, |_| {})
+ .expect("snapshot")
+}
+
+#[test]
+fn sessions_get_thread_names_and_rank_by_cost() {
+ let tmp = TempDir::new().unwrap();
+ let snapshot = build_home(tmp.path(), true);
+
+ let titles: Vec<_> = snapshot
+ .sessions
+ .iter()
+ .map(|s| s.display_title.as_str())
+ .collect();
+ // Most expensive first; an untitled session shows its short id.
+ assert_eq!(
+ titles,
+ ["Session 019f...1e92b191", "Fix the icon", "Database title"]
+ );
+ let top: Vec<_> = snapshot.projects[0]
+ .top_sessions
+ .iter()
+ .map(|s| s.display_title.as_str())
+ .collect();
+ assert_eq!(top, titles);
+}
+
+#[test]
+fn untitled_sessions_show_the_short_id() {
+ let tmp = TempDir::new().unwrap();
+ let snapshot = build_home(tmp.path(), false);
+ let titles: Vec<_> = snapshot
+ .sessions
+ .iter()
+ .map(|s| s.display_title.clone())
+ .collect();
+ assert_eq!(
+ titles,
+ [
+ format!(
+ "Session {}",
+ short_session_id("019f79b9-1790-7921-8d6f-258a1e92b191")
+ ),
+ "Session midd...-session".to_string(),
+ "Session short-id".to_string(),
+ ]
+ );
+}
+
+#[test]
+fn naming_and_ranking_do_not_change_totals_or_costs() {
+ let plain = tempfile::tempdir().unwrap();
+ let named = tempfile::tempdir().unwrap();
+ let plain = build_home(plain.path(), false);
+ let named = build_home(named.path(), true);
+
+ assert_eq!(plain.total, named.total);
+ assert_eq!(plain.daily, named.daily);
+ assert_eq!(plain.projects.len(), named.projects.len());
+ assert_eq!(
+ plain.projects[0].cost_estimate,
+ named.projects[0].cost_estimate
+ );
+ let costs = |snapshot: &CodexLocalProjectUsageSnapshot| {
+ snapshot
+ .sessions
+ .iter()
+ .map(|s| (s.id.clone(), s.cost_estimate, s.totals))
+ .collect::>()
+ };
+ assert_eq!(costs(&plain), costs(&named));
+}
+
+#[test]
+fn hide_personal_info_masks_names_with_the_short_id() {
+ let tmp = TempDir::new().unwrap();
+ let mut snapshot = build_home(tmp.path(), true);
+ snapshot.redact_for_privacy();
+
+ let titles: Vec<_> = snapshot
+ .sessions
+ .iter()
+ .map(|s| s.display_title.as_str())
+ .collect();
+ assert_eq!(
+ titles,
+ [
+ "Session 019f...1e92b191",
+ "Session midd...-session",
+ "Session short-id"
+ ]
+ );
+ assert!(snapshot.sessions.iter().all(|s| s.cwd.is_none()));
+ assert!(snapshot.projects.iter().all(|p| p.path.is_none()));
+ assert!(
+ snapshot.projects[0]
+ .top_sessions
+ .iter()
+ .all(|s| s.display_title.starts_with("Session "))
+ );
+}
diff --git a/rust/src/codex_workspaces/thread_names.rs b/rust/src/codex_workspaces/thread_names.rs
new file mode 100644
index 00000000000..18d5f06cb41
--- /dev/null
+++ b/rust/src/codex_workspaces/thread_names.rs
@@ -0,0 +1,334 @@
+//! Codex thread names for indexed sessions (upstream 0.68.0 `CodexThreadMetadataReader`).
+//!
+//! Codex keeps thread names outside the rollout files, so they are overlaid onto
+//! usage snapshots. Order: `session_index.jsonl` in the Codex home (latest line
+//! wins), then `threads.title` in the newest `state_.sqlite`. Everything is
+//! read-only and best effort: any failure leaves the session untitled.
+
+use std::collections::{HashMap, HashSet};
+use std::fs::{self, File};
+use std::io::{BufRead, BufReader, Read};
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use rusqlite::{Connection, OpenFlags, OptionalExtension};
+use serde::Deserialize;
+
+use super::types::{CodexLocalProjectUsageSnapshot, SessionUsage, untitled_session_label};
+
+/// Lines longer than this in `session_index.jsonl` are skipped.
+const SESSION_INDEX_MAX_LINE_BYTES: u64 = 64 * 1024;
+/// Only the head of `config.toml` is read when looking for `sqlite_home`.
+const CONFIG_PREFIX_BYTES: u64 = 256 * 1024;
+const BUSY_TIMEOUT: Duration = Duration::from_millis(100);
+const DEFAULT_STATE_DATABASE: &str = "state_5.sqlite";
+const PROJECT_TOP_SESSION_LIMIT: usize = 5;
+
+/// A session whose thread name is wanted: its id and original working directory.
+pub(super) struct SessionRef<'a> {
+ pub id: &'a str,
+ pub cwd: Option<&'a str>,
+}
+
+#[derive(Deserialize)]
+struct SessionIndexEntry {
+ id: String,
+ thread_name: String,
+}
+
+/// Thread names keyed by session id for the given sessions.
+///
+/// `env_sqlite_home` is the raw `CODEX_SQLITE_HOME` value, passed in so callers
+/// (and tests) control the environment.
+pub(super) fn thread_titles(
+ codex_home: &Path,
+ env_sqlite_home: Option<&str>,
+ sessions: &[SessionRef<'_>],
+) -> HashMap {
+ if sessions.is_empty() {
+ return HashMap::new();
+ }
+ let ids: HashSet<&str> = sessions.iter().map(|s| s.id).collect();
+ let mut titles = indexed_thread_names(&codex_home.join("session_index.jsonl"), &ids);
+ if titles.len() == ids.len() {
+ return titles;
+ }
+
+ let configured_home = configured_sqlite_home(codex_home);
+ let mut newest_by_dir: HashMap = HashMap::new();
+ let mut ids_by_database: HashMap> = HashMap::new();
+ for session in sessions {
+ if titles.contains_key(session.id) {
+ continue;
+ }
+ let dir = sqlite_home(
+ codex_home,
+ configured_home.as_deref(),
+ env_sqlite_home,
+ session.cwd,
+ );
+ let database = newest_by_dir
+ .entry(dir.clone())
+ .or_insert_with(|| newest_state_database(&dir))
+ .clone();
+ ids_by_database
+ .entry(database)
+ .or_default()
+ .push(session.id);
+ }
+ for (database, session_ids) in ids_by_database {
+ for (id, title) in database_titles(&database, &session_ids) {
+ // The session index is the explicit name; the database is the fallback.
+ titles.entry(id).or_insert(title);
+ }
+ }
+ titles
+}
+
+/// Apply metadata names and ranking to fresh and cached snapshots.
+/// Names never touch totals, cost, or project grouping.
+pub(super) fn apply_session_names_and_ranking(
+ snapshot: &mut CodexLocalProjectUsageSnapshot,
+ codex_home: &Path,
+ env_sqlite_home: Option<&str>,
+) {
+ let has_sessions = !snapshot.sessions.is_empty();
+ let titles = {
+ let mut seen = HashSet::new();
+ let mut refs = Vec::new();
+ for session in &snapshot.sessions {
+ if seen.insert(session.id.as_str()) {
+ refs.push(SessionRef {
+ id: &session.id,
+ cwd: session.cwd.as_deref(),
+ });
+ }
+ }
+ if !has_sessions {
+ for session in snapshot
+ .projects
+ .iter()
+ .flat_map(|project| project.top_sessions.iter())
+ {
+ if seen.insert(session.id.as_str()) {
+ refs.push(SessionRef {
+ id: &session.id,
+ cwd: session.cwd.as_deref(),
+ });
+ }
+ }
+ }
+ thread_titles(codex_home, env_sqlite_home, &refs)
+ };
+
+ for session in &mut snapshot.sessions {
+ apply_thread_title(session, &titles);
+ }
+ snapshot.sessions.sort_by(SessionUsage::rank_cmp);
+
+ if has_sessions {
+ let mut top_sessions_by_project: HashMap> = HashMap::new();
+ for session in &snapshot.sessions {
+ let top_sessions = top_sessions_by_project
+ .entry(session.project_id.clone())
+ .or_default();
+ if top_sessions.len() < PROJECT_TOP_SESSION_LIMIT {
+ top_sessions.push(session.clone());
+ }
+ }
+ for project in &mut snapshot.projects {
+ project.top_sessions = top_sessions_by_project
+ .remove(&project.id)
+ .unwrap_or_default();
+ }
+ } else {
+ for project in &mut snapshot.projects {
+ for session in &mut project.top_sessions {
+ apply_thread_title(session, &titles);
+ }
+ project.top_sessions.sort_by(SessionUsage::rank_cmp);
+ project.top_sessions.truncate(PROJECT_TOP_SESSION_LIMIT);
+ }
+ }
+}
+
+fn apply_thread_title(session: &mut SessionUsage, titles: &HashMap) {
+ session.display_title = titles
+ .get(&session.id)
+ .cloned()
+ .unwrap_or_else(|| untitled_session_label(&session.id));
+}
+
+/// `session_index.jsonl`: `{"id": ..., "thread_name": ...}` per line, latest wins.
+fn indexed_thread_names(path: &Path, ids: &HashSet<&str>) -> HashMap {
+ let mut names = HashMap::new();
+ let Ok(file) = File::open(path) else {
+ return names;
+ };
+ let mut reader = BufReader::new(file);
+ let mut line = Vec::new();
+ loop {
+ line.clear();
+ let Ok(read) = (&mut reader)
+ .take(SESSION_INDEX_MAX_LINE_BYTES + 1)
+ .read_until(b'\n', &mut line)
+ else {
+ break;
+ };
+ if read == 0 {
+ break;
+ }
+ if line.last() != Some(&b'\n') && read as u64 > SESSION_INDEX_MAX_LINE_BYTES {
+ // Over-long line: drop the remainder without buffering it.
+ if !skip_to_line_end(&mut reader) {
+ break;
+ }
+ continue;
+ }
+ let Ok(entry) = serde_json::from_slice::(&line) else {
+ continue;
+ };
+ let name = entry.thread_name.trim();
+ if !name.is_empty() && ids.contains(entry.id.as_str()) {
+ names.insert(entry.id, name.to_string());
+ }
+ }
+ names
+}
+
+/// Consume input up to and including the next newline. Returns `false` at EOF or on error.
+fn skip_to_line_end(reader: &mut R) -> bool {
+ loop {
+ let Ok(buffer) = reader.fill_buf() else {
+ return false;
+ };
+ if buffer.is_empty() {
+ return false;
+ }
+ match buffer.iter().position(|byte| *byte == b'\n') {
+ Some(index) => {
+ reader.consume(index + 1);
+ return true;
+ }
+ None => {
+ let len = buffer.len();
+ reader.consume(len);
+ }
+ }
+ }
+}
+
+/// Directory holding `state_.sqlite` for one session.
+fn sqlite_home(
+ codex_home: &Path,
+ configured_home: Option<&Path>,
+ env_sqlite_home: Option<&str>,
+ cwd: Option<&str>,
+) -> PathBuf {
+ if let Some(configured) = configured_home {
+ return configured.to_path_buf();
+ }
+ if let Some(raw) = env_sqlite_home.map(str::trim).filter(|v| !v.is_empty()) {
+ let path = Path::new(raw);
+ if path.is_absolute() {
+ return path.to_path_buf();
+ }
+ if let Some(cwd) = cwd.map(str::trim).filter(|c| !c.is_empty()) {
+ return Path::new(cwd).join(path);
+ }
+ }
+ codex_home.to_path_buf()
+}
+
+/// Absolute top-level `sqlite_home` from `/config.toml`.
+///
+/// Only the user config is read: project, managed and profile layers are not
+/// persisted with the rollout and an untrusted checkout must not redirect the
+/// lookup. Only the first 256 KiB is read.
+fn configured_sqlite_home(codex_home: &Path) -> Option {
+ let file = File::open(codex_home.join("config.toml")).ok()?;
+ let mut bytes = Vec::new();
+ file.take(CONFIG_PREFIX_BYTES)
+ .read_to_end(&mut bytes)
+ .ok()?;
+ let contents = String::from_utf8_lossy(&bytes);
+ let raw = top_level_sqlite_home(&contents)?;
+ let path = PathBuf::from(raw);
+ path.is_absolute().then_some(path)
+}
+
+fn top_level_sqlite_home(contents: &str) -> Option {
+ let table = contents.parse::().ok().or_else(|| {
+ // A truncated or malformed table section must not hide a valid
+ // top-level key, so retry with everything before the first table header.
+ let mut offset = 0usize;
+ let mut end = contents.len();
+ for line in contents.split_inclusive('\n') {
+ if line.trim_start().starts_with('[') {
+ end = offset;
+ break;
+ }
+ offset += line.len();
+ }
+ contents[..end].parse::().ok()
+ })?;
+ match table.get("sqlite_home")? {
+ toml::Value::String(value) => Some(value.clone()),
+ _ => None,
+ }
+}
+
+/// Highest `state_.sqlite` in `dir`, else the default `state_5.sqlite` path.
+fn newest_state_database(dir: &Path) -> PathBuf {
+ let newest = fs::read_dir(dir).ok().and_then(|entries| {
+ entries
+ .flatten()
+ .filter_map(|entry| {
+ let name = entry.file_name().into_string().ok()?;
+ let version = name
+ .strip_prefix("state_")?
+ .strip_suffix(".sqlite")?
+ .parse::()
+ .ok()?;
+ if !entry.metadata().ok()?.is_file() {
+ return None;
+ }
+ Some((version, entry.path()))
+ })
+ .max_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)))
+ .map(|(_, path)| path)
+ });
+ newest.unwrap_or_else(|| dir.join(DEFAULT_STATE_DATABASE))
+}
+
+/// `SELECT title FROM threads WHERE id = ?1 LIMIT 1` for each id, read-only.
+fn database_titles(database: &Path, ids: &[&str]) -> Vec<(String, String)> {
+ if !database.is_file() {
+ return Vec::new();
+ }
+ let Ok(conn) = Connection::open_with_flags(
+ database,
+ OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
+ ) else {
+ return Vec::new();
+ };
+ if conn.busy_timeout(BUSY_TIMEOUT).is_err() {
+ return Vec::new();
+ }
+ let Ok(mut statement) = conn.prepare("SELECT title FROM threads WHERE id = ?1 LIMIT 1") else {
+ return Vec::new();
+ };
+ ids.iter()
+ .filter_map(|id| {
+ let title = statement
+ .query_row([id], |row| row.get::<_, Option>(0))
+ .optional()
+ .ok()??;
+ let title = title?.trim().to_string();
+ (!title.is_empty()).then(|| ((*id).to_string(), title))
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/rust/src/codex_workspaces/thread_names/tests.rs b/rust/src/codex_workspaces/thread_names/tests.rs
new file mode 100644
index 00000000000..287cf639bc5
--- /dev/null
+++ b/rust/src/codex_workspaces/thread_names/tests.rs
@@ -0,0 +1,239 @@
+use super::*;
+use tempfile::TempDir;
+
+fn session<'a>(id: &'a str, cwd: Option<&'a str>) -> SessionRef<'a> {
+ SessionRef { id, cwd }
+}
+
+fn write_state(dir: &Path, file: &str, rows: &[(&str, Option<&str>)]) {
+ fs::create_dir_all(dir).unwrap();
+ let conn = Connection::open(dir.join(file)).unwrap();
+ conn.execute_batch("CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT)")
+ .unwrap();
+ for (id, title) in rows {
+ conn.execute(
+ "INSERT INTO threads (id, title) VALUES (?1, ?2)",
+ (id, title),
+ )
+ .unwrap();
+ }
+}
+
+fn write_index(home: &Path, contents: &str) {
+ fs::create_dir_all(home).unwrap();
+ fs::write(home.join("session_index.jsonl"), contents).unwrap();
+}
+
+fn titles(home: &Path, env: Option<&str>, sessions: &[SessionRef<'_>]) -> HashMap {
+ thread_titles(home, env, sessions)
+}
+
+fn toml_path(path: &Path) -> String {
+ path.to_string_lossy().replace('\\', "\\\\")
+}
+
+#[test]
+fn session_index_latest_line_wins_and_blank_names_are_ignored() {
+ let tmp = TempDir::new().unwrap();
+ write_index(
+ tmp.path(),
+ concat!(
+ "{\"id\":\"a\",\"thread_name\":\"First\"}\n",
+ "{\"id\":\"a\",\"thread_name\":\" Renamed \"}\n",
+ "{\"id\":\"b\",\"thread_name\":\" \"}\n",
+ "{\"id\":\"c\",\"thread_name\":\"Not requested\"}\n",
+ "not json\n",
+ "{\"id\":\"d\",\"thread_name\":\"No trailing newline\"}",
+ ),
+ );
+ let found = titles(
+ tmp.path(),
+ None,
+ &[session("a", None), session("b", None), session("d", None)],
+ );
+ assert_eq!(found.get("a").map(String::as_str), Some("Renamed"));
+ assert_eq!(
+ found.get("d").map(String::as_str),
+ Some("No trailing newline")
+ );
+ assert!(!found.contains_key("b"));
+ assert!(!found.contains_key("c"));
+}
+
+#[test]
+fn session_index_skips_lines_over_64_kib() {
+ let tmp = TempDir::new().unwrap();
+ let huge = "x".repeat(70 * 1024);
+ write_index(
+ tmp.path(),
+ &format!(
+ "{{\"id\":\"a\",\"thread_name\":\"{huge}\"}}\n{{\"id\":\"b\",\"thread_name\":\"After\"}}\n"
+ ),
+ );
+ let found = titles(tmp.path(), None, &[session("a", None), session("b", None)]);
+ assert!(!found.contains_key("a"));
+ assert_eq!(found.get("b").map(String::as_str), Some("After"));
+}
+
+#[test]
+fn database_title_is_the_fallback_and_the_index_wins() {
+ let tmp = TempDir::new().unwrap();
+ write_state(
+ tmp.path(),
+ "state_5.sqlite",
+ &[
+ ("a", Some("Database name")),
+ ("b", Some(" Trimmed ")),
+ ("c", None),
+ ("d", Some(" ")),
+ ],
+ );
+ write_index(
+ tmp.path(),
+ "{\"id\":\"a\",\"thread_name\":\"Index name\"}\n",
+ );
+ let found = titles(
+ tmp.path(),
+ None,
+ &[
+ session("a", None),
+ session("b", None),
+ session("c", None),
+ session("d", None),
+ session("missing", None),
+ ],
+ );
+ assert_eq!(found.get("a").map(String::as_str), Some("Index name"));
+ assert_eq!(found.get("b").map(String::as_str), Some("Trimmed"));
+ assert_eq!(found.len(), 2);
+}
+
+#[test]
+fn newest_state_database_version_is_used() {
+ let tmp = TempDir::new().unwrap();
+ write_state(tmp.path(), "state_5.sqlite", &[("a", Some("old"))]);
+ write_state(tmp.path(), "state_10.sqlite", &[("a", Some("newest"))]);
+ write_state(tmp.path(), "state_7.sqlite", &[("a", Some("middle"))]);
+ let found = titles(tmp.path(), None, &[session("a", None)]);
+ assert_eq!(found.get("a").map(String::as_str), Some("newest"));
+}
+
+#[test]
+fn missing_or_corrupt_database_yields_no_titles() {
+ let tmp = TempDir::new().unwrap();
+ assert!(titles(tmp.path(), None, &[session("a", None)]).is_empty());
+ fs::write(tmp.path().join("state_5.sqlite"), b"not a database").unwrap();
+ assert!(titles(tmp.path(), None, &[session("a", None)]).is_empty());
+}
+
+#[test]
+fn config_sqlite_home_must_be_absolute_and_top_level() {
+ let tmp = TempDir::new().unwrap();
+ let home = tmp.path().join("home");
+ let other = tmp.path().join("other");
+ fs::create_dir_all(&home).unwrap();
+ write_state(&home, "state_5.sqlite", &[("a", Some("from home"))]);
+ write_state(&other, "state_5.sqlite", &[("a", Some("from config"))]);
+
+ let config = home.join("config.toml");
+ fs::write(
+ &config,
+ format!("sqlite_home = \"{}\"\n", toml_path(&other)),
+ )
+ .unwrap();
+ let found = titles(&home, None, &[session("a", None)]);
+ assert_eq!(found.get("a").map(String::as_str), Some("from config"));
+
+ fs::write(&config, "sqlite_home = \"relative/dir\"\n").unwrap();
+ let found = titles(&home, None, &[session("a", None)]);
+ assert_eq!(found.get("a").map(String::as_str), Some("from home"));
+
+ fs::write(
+ &config,
+ format!(
+ "model = \"x\"\n[projects.p]\nsqlite_home = \"{}\"\n",
+ toml_path(&other)
+ ),
+ )
+ .unwrap();
+ let found = titles(&home, None, &[session("a", None)]);
+ assert_eq!(found.get("a").map(String::as_str), Some("from home"));
+}
+
+#[test]
+fn config_top_level_key_survives_a_malformed_table_section() {
+ let tmp = TempDir::new().unwrap();
+ let home = tmp.path().join("home");
+ let other = tmp.path().join("other");
+ fs::create_dir_all(&home).unwrap();
+ write_state(&other, "state_5.sqlite", &[("a", Some("from config"))]);
+ fs::write(
+ home.join("config.toml"),
+ format!(
+ "sqlite_home = \"{}\"\r\n[broken\r\nkey = \r\n",
+ toml_path(&other)
+ ),
+ )
+ .unwrap();
+ let found = titles(&home, None, &[session("a", None)]);
+ assert_eq!(found.get("a").map(String::as_str), Some("from config"));
+}
+
+#[test]
+fn config_beats_environment_which_beats_codex_home() {
+ let tmp = TempDir::new().unwrap();
+ let home = tmp.path().join("home");
+ let from_config = tmp.path().join("cfg");
+ let from_env = tmp.path().join("env");
+ fs::create_dir_all(&home).unwrap();
+ write_state(&home, "state_5.sqlite", &[("a", Some("home"))]);
+ write_state(&from_env, "state_5.sqlite", &[("a", Some("env"))]);
+ write_state(&from_config, "state_5.sqlite", &[("a", Some("config"))]);
+ let env = from_env.to_string_lossy().into_owned();
+
+ let found = titles(&home, Some(&env), &[session("a", None)]);
+ assert_eq!(found.get("a").map(String::as_str), Some("env"));
+
+ fs::write(
+ home.join("config.toml"),
+ format!("sqlite_home = '{}'\n", from_config.display()),
+ )
+ .unwrap();
+ let found = titles(&home, Some(&env), &[session("a", None)]);
+ assert_eq!(found.get("a").map(String::as_str), Some("config"));
+}
+
+#[test]
+fn relative_environment_home_resolves_against_the_session_cwd() {
+ let tmp = TempDir::new().unwrap();
+ let home = tmp.path().join("home");
+ let cwd_one = tmp.path().join("repo-one");
+ let cwd_two = tmp.path().join("repo-two");
+ fs::create_dir_all(&home).unwrap();
+ write_state(&home, "state_5.sqlite", &[("c", Some("home title"))]);
+ write_state(&cwd_one.join("db"), "state_5.sqlite", &[("a", Some("one"))]);
+ write_state(&cwd_two.join("db"), "state_5.sqlite", &[("b", Some("two"))]);
+ let one = cwd_one.to_string_lossy().into_owned();
+ let two = cwd_two.to_string_lossy().into_owned();
+
+ let found = titles(
+ &home,
+ Some("db"),
+ &[
+ session("a", Some(&one)),
+ session("b", Some(&two)),
+ // No working directory: a relative path cannot be resolved, use the home.
+ session("c", None),
+ ],
+ );
+ assert_eq!(found.get("a").map(String::as_str), Some("one"));
+ assert_eq!(found.get("b").map(String::as_str), Some("two"));
+ assert_eq!(found.get("c").map(String::as_str), Some("home title"));
+}
+
+#[test]
+fn no_sessions_reads_nothing() {
+ let tmp = TempDir::new().unwrap();
+ write_index(tmp.path(), "{\"id\":\"a\",\"thread_name\":\"x\"}\n");
+ assert!(titles(tmp.path(), None, &[]).is_empty());
+}
diff --git a/rust/src/codex_workspaces/types.rs b/rust/src/codex_workspaces/types.rs
index e96af15da3e..2849069bc3a 100644
--- a/rust/src/codex_workspaces/types.rs
+++ b/rust/src/codex_workspaces/types.rs
@@ -1,5 +1,7 @@
//! Snapshot DTOs for Codex local Workspaces indexing.
+use std::cmp::Ordering;
+
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@@ -132,6 +134,48 @@ pub struct SessionUsage {
pub top_model: Option,
}
+impl SessionUsage {
+ /// Cost used for ranking: `None` when nothing could be priced, so unpriced
+ /// sessions sort after priced ones (a priced zero stays priced).
+ pub fn ranking_cost_usd(&self) -> Option {
+ let cost = self.cost_estimate;
+ (cost.unknown_tokens == 0 || cost.known_usd > 0.0).then_some(cost.known_usd)
+ }
+
+ /// Ranking order for the sessions panel: cost descending with unpriced
+ /// last, then tokens descending, latest activity descending, id ascending.
+ pub fn rank_cmp(&self, other: &Self) -> Ordering {
+ match (self.ranking_cost_usd(), other.ranking_cost_usd()) {
+ (Some(left), Some(right)) => right.total_cmp(&left),
+ (Some(_), None) => Ordering::Less,
+ (None, Some(_)) => Ordering::Greater,
+ (None, None) => Ordering::Equal,
+ }
+ .then_with(|| other.totals.total_tokens.cmp(&self.totals.total_tokens))
+ .then_with(|| other.latest_activity.cmp(&self.latest_activity))
+ .then_with(|| self.id.cmp(&other.id))
+ }
+}
+
+/// Short display form of a session id: ids over 12 characters keep the first 4
+/// and last 8 (`019f...1e92b191`).
+pub fn short_session_id(value: &str) -> String {
+ let trimmed = value.trim();
+ let count = trimmed.chars().count();
+ if count <= 12 {
+ return trimmed.to_string();
+ }
+ let prefix: String = trimmed.chars().take(4).collect();
+ let suffix: String = trimmed.chars().skip(count - 8).collect();
+ format!("{prefix}...{suffix}")
+}
+
+/// Label for a session without a thread name, and the masked label under
+/// Hide personal information. The session id is not personal data.
+pub fn untitled_session_label(session_id: &str) -> String {
+ format!("Session {}", short_session_id(session_id))
+}
+
/// Per-project usage aggregate.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -170,7 +214,7 @@ impl CodexLocalProjectUsageSnapshot {
/// Does not rewrite the sidecar.
pub fn redact_for_privacy(&mut self) {
for session in &mut self.sessions {
- session.display_title = "Local Codex chat".to_string();
+ session.display_title = untitled_session_label(&session.id);
session.cwd = None;
}
for project in &mut self.projects {
@@ -181,9 +225,12 @@ impl CodexLocalProjectUsageSnapshot {
}
project.path = None;
for session in &mut project.top_sessions {
- session.display_title = "Local Codex chat".to_string();
+ session.display_title = untitled_session_label(&session.id);
session.cwd = None;
}
}
}
}
+
+#[cfg(test)]
+mod tests;
diff --git a/rust/src/codex_workspaces/types/tests.rs b/rust/src/codex_workspaces/types/tests.rs
new file mode 100644
index 00000000000..25df8bbdd21
--- /dev/null
+++ b/rust/src/codex_workspaces/types/tests.rs
@@ -0,0 +1,82 @@
+use super::*;
+
+fn session(id: &str, cost: Option, tokens: u64, minutes: i64) -> SessionUsage {
+ SessionUsage {
+ id: id.to_string(),
+ project_id: "p".to_string(),
+ display_title: id.to_string(),
+ cwd: None,
+ started_at: None,
+ latest_activity: Some(DateTime::::UNIX_EPOCH + chrono::Duration::minutes(minutes)),
+ totals: UsageTotals::from_parts(tokens, 0, 0),
+ cost_estimate: match cost {
+ Some(known_usd) => CostEstimate {
+ known_usd,
+ unknown_tokens: 0,
+ },
+ None => CostEstimate {
+ known_usd: 0.0,
+ unknown_tokens: tokens,
+ },
+ },
+ top_model: None,
+ }
+}
+
+fn ranked(mut sessions: Vec) -> Vec {
+ sessions.sort_by(SessionUsage::rank_cmp);
+ sessions.into_iter().map(|s| s.id).collect()
+}
+
+#[test]
+fn ranking_orders_by_cost_then_tokens_then_activity_then_id_with_unpriced_last() {
+ let sessions = vec![
+ session("b", Some(1.0), 10, 5),
+ session("a", Some(1.0), 10, 5),
+ session("older", Some(1.0), 10, 1),
+ session("more-tokens", Some(1.0), 20, 5),
+ session("free", Some(0.0), 10, 5),
+ session("unpriced", None, 999, 5),
+ session("expensive", Some(5.0), 1, 0),
+ ];
+ let expected = [
+ "expensive",
+ "more-tokens",
+ "a",
+ "b",
+ "older",
+ "free",
+ "unpriced",
+ ];
+ for offset in 0..sessions.len() {
+ let mut rotated = sessions.clone();
+ rotated.rotate_left(offset);
+ assert_eq!(ranked(rotated.clone()), expected);
+ rotated.reverse();
+ assert_eq!(ranked(rotated), expected);
+ }
+}
+
+#[test]
+fn partially_priced_sessions_rank_by_their_known_cost() {
+ let mut partial = session("partial", Some(2.0), 10, 0);
+ partial.cost_estimate.unknown_tokens = 4;
+ assert_eq!(partial.ranking_cost_usd(), Some(2.0));
+ let unpriced = session("unpriced", None, 10, 0);
+ assert_eq!(unpriced.ranking_cost_usd(), None);
+}
+
+#[test]
+fn short_session_id_keeps_first_four_and_last_eight_of_long_ids() {
+ assert_eq!(short_session_id("abc"), "abc");
+ assert_eq!(short_session_id(" exactly-12ch "), "exactly-12ch");
+ assert_eq!(
+ short_session_id("019f79b9-1790-7921-8d6f-258a1e92b191"),
+ "019f...1e92b191"
+ );
+ assert_eq!(short_session_id("fixture-session"), "fixt...-session");
+ assert_eq!(
+ untitled_session_label("019f79b9-1790-7921-8d6f-258a1e92b191"),
+ "Session 019f...1e92b191"
+ );
+}