Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -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(<ProjectConversations conversations={[session(1), session(2, 5)]} t={t} />);

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(<ProjectConversations conversations={conversations} t={t} />);

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);
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ display: "grid", gap: 5, marginTop: 9, paddingTop: 8, borderTop: "1px solid var(--border-subtle)" }}>
{visible.map((session, index) => (
<div
key={session.id}
style={{ display: "grid", gridTemplateColumns: "auto minmax(0, 1fr) auto", gap: 10 }}
>
<span className="settings-section__caption" style={{ minWidth: "2.5ch", textAlign: "right" }}>
#{index + 1}
</span>
<span
title={session.displayTitle}
style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{session.displayTitle}
</span>
<span>
{session.costEstimate.unknownTokens > 0 ? "~" : ""}
${session.costEstimate.knownUsd.toFixed(2)}
</span>
</div>
))}
{capped.length > CONVERSATION_INITIAL_ROWS && (
<button
type="button"
className="credential-btn credential-btn--secondary"
style={{ justifySelf: "start" }}
aria-expanded={showAll}
onClick={() => setShowAll((value) => !value)}
>
{showAll ? t("UsageSpendShowLess") : `${t("UsageSpendShowAll")} (${capped.length})`}
</button>
)}
</div>
);
}
23 changes: 5 additions & 18 deletions apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -471,24 +472,10 @@ function ProjectsPanel({
</button>

{isExpanded && (
<div style={{ display: "grid", gap: 5, marginTop: 9, paddingTop: 8, borderTop: "1px solid var(--border-subtle)" }}>
{contract.conversations
.filter((session) => session.projectId === project.id)
.map((session) => (
<div
key={session.id}
style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) auto", gap: 10 }}
>
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{session.displayTitle}
</span>
<span>
{session.costEstimate.unknownTokens > 0 ? "~" : ""}
${session.costEstimate.knownUsd.toFixed(2)}
</span>
</div>
))}
</div>
<ProjectConversations
conversations={contract.conversations.filter((session) => session.projectId === project.id)}
t={t}
/>
)}
</div>
);
Expand Down
24 changes: 1 addition & 23 deletions rust/src/cli/cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{prefix}...{suffix}")
}

/// Print JSON output
fn build_json_payloads(results: &[CostResult], days: u32) -> Vec<serde_json::Value> {
let settings = Settings::load();
Expand Down Expand Up @@ -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(
Expand Down
39 changes: 18 additions & 21 deletions rust/src/codex_workspaces/indexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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<Self> {
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))
Expand Down Expand Up @@ -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) => {}
Expand Down Expand Up @@ -241,17 +246,12 @@ impl CodexWorkspacesIndex {
.collect();
daily.sort_by(|a, b| a.day.cmp(&b.day));

let mut sessions: Vec<SessionUsage> = session_buckets
let sessions: Vec<SessionUsage> = 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(),
Expand All @@ -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)?;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -412,11 +417,6 @@ fn build_projects(sessions: &HashMap<String, SessionBucket>) -> Vec<ProjectUsage
.into_iter()
.max_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
.map(|(m, _)| m);
let top_sessions = buckets
.iter()
.take(5)
.map(|b| b.to_session_usage())
.collect();
ProjectUsage {
id: first.project_id.clone(),
display_name: first.project_display_name.clone(),
Expand All @@ -427,7 +427,7 @@ fn build_projects(sessions: &HashMap<String, SessionBucket>) -> Vec<ProjectUsage
session_count: buckets.len() as u32,
latest_activity: latest,
top_model,
top_sessions,
top_sessions: Vec::new(),
}
})
.collect()
Expand Down Expand Up @@ -955,10 +955,7 @@ mod tests {
snap.redact_for_privacy();
assert_eq!(snap.projects[0].display_name, "Workspace");
assert!(snap.projects[0].path.is_none());
assert_eq!(
snap.projects[0].top_sessions[0].display_title,
"Local Codex chat"
);
assert_eq!(snap.projects[0].top_sessions[0].display_title, "Session s1");
assert!(snap.projects[0].top_sessions[0].cwd.is_none());
}
}
7 changes: 6 additions & 1 deletion rust/src/codex_workspaces/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@

mod indexer;
mod sidecar;
mod thread_names;
mod types;

#[cfg(test)]
mod tests;

pub use indexer::{CodexLocalDataScope, CodexWorkspacesIndex, IndexError, project_identity};
pub use sidecar::{PAYLOAD_FORMAT_VERSION, SCHEMA_VERSION, SidecarError, WorkspaceUsageSidecar};
pub use types::{
CodexLocalProjectUsageSnapshot, CostEstimate, DailyPoint, Progress, ProgressPhase,
ProjectUsage, SessionUsage, SourceStatus, UsageTotals,
ProjectUsage, SessionUsage, SourceStatus, UsageTotals, short_session_id,
untitled_session_label,
};

pub const CHATS_PROJECT_ID: &str = "chats";
Expand Down
Loading