diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index b073d71..aefaa30 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -43,6 +43,7 @@ pub fn open_database(database_path: &Path) -> Result { rows_json TEXT NOT NULL, prompt_title TEXT, quota_usage_json TEXT, + agent_metadata_json TEXT, quota_parser_version INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL ); @@ -67,6 +68,12 @@ pub fn open_database(database_path: &Path) -> Result { "prompt_title", "ALTER TABLE session_file_rollups ADD COLUMN prompt_title TEXT", )?; + ensure_column( + &db, + "session_file_rollups", + "agent_metadata_json", + "ALTER TABLE session_file_rollups ADD COLUMN agent_metadata_json TEXT", + )?; ensure_column( &db, "session_file_rollups", @@ -76,6 +83,16 @@ pub fn open_database(database_path: &Path) -> Result { Ok(db) } +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SessionAgentMetadata { + pub thread_id: Option, + pub parent_thread_id: Option, + pub agent_path: Option, + pub agent_nickname: Option, + pub agent_role: Option, +} + #[derive(Debug, Clone)] pub struct SessionFileRollup { pub path: String, @@ -84,6 +101,7 @@ pub struct SessionFileRollup { pub rows: Vec, pub prompt_title: Option, pub quota_usage: Option, + pub agent_metadata: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -249,7 +267,7 @@ pub fn query_session_file_rollup( ) -> Result, String> { let result = db.query_row( r#" - SELECT rows_json, prompt_title, quota_usage_json + SELECT rows_json, prompt_title, quota_usage_json, agent_metadata_json FROM session_file_rollups WHERE path = ? AND modified_at_ms = ? AND size_bytes = ? AND quota_parser_version = ? "#, @@ -259,16 +277,20 @@ pub fn query_session_file_rollup( row.get::<_, String>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, )) }, ); match result { - Ok((rows_json, prompt_title, quota_usage_json)) => { + Ok((rows_json, prompt_title, quota_usage_json, agent_metadata_json)) => { let rows = serde_json::from_str(&rows_json).map_err(|error| error.to_string())?; let quota_usage = quota_usage_json .map(|json| serde_json::from_str(&json).map_err(|error| error.to_string())) .transpose()?; + let agent_metadata = agent_metadata_json + .map(|json| serde_json::from_str(&json).map_err(|error| error.to_string())) + .transpose()?; Ok(Some(SessionFileRollup { path: path.to_string(), modified_at_ms, @@ -276,6 +298,7 @@ pub fn query_session_file_rollup( rows, prompt_title, quota_usage, + agent_metadata, })) } Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), @@ -356,15 +379,17 @@ pub fn upsert_session_file_rollups( rows_json, prompt_title, quota_usage_json, + agent_metadata_json, quota_parser_version, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(path) DO UPDATE SET modified_at_ms = excluded.modified_at_ms, size_bytes = excluded.size_bytes, rows_json = excluded.rows_json, prompt_title = excluded.prompt_title, quota_usage_json = excluded.quota_usage_json, + agent_metadata_json = excluded.agent_metadata_json, quota_parser_version = excluded.quota_parser_version, updated_at = excluded.updated_at "#, @@ -380,6 +405,12 @@ pub fn upsert_session_file_rollups( .map(serde_json::to_string) .transpose() .map_err(|error| error.to_string())?; + let agent_metadata_json = rollup + .agent_metadata + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|error| error.to_string())?; statement .execute(params![ rollup.path, @@ -388,6 +419,7 @@ pub fn upsert_session_file_rollups( rows_json, rollup.prompt_title, quota_usage_json, + agent_metadata_json, QUOTA_PARSER_VERSION, updated_at ]) @@ -502,7 +534,8 @@ pub fn query_session_details(db: &Connection) -> Result, S size_bytes, rows_json, prompt_title, - quota_usage_json + quota_usage_json, + agent_metadata_json FROM session_file_rollups ORDER BY modified_at_ms DESC "#, @@ -519,6 +552,10 @@ pub fn query_session_details(db: &Connection) -> Result, S let quota_usage = row .get::<_, Option>(5)? .and_then(|json| serde_json::from_str::(&json).ok()); + let agent_metadata = row + .get::<_, Option>(6)? + .and_then(|json| serde_json::from_str::(&json).ok()) + .unwrap_or_default(); let daily_rows = serde_json::from_str::>(&rows_json).unwrap_or_default(); @@ -573,12 +610,14 @@ pub fn query_session_details(db: &Connection) -> Result, S path, session_id, thread_name: prompt_title.filter(|title| !title.is_empty()), + thread_id: agent_metadata.thread_id, + parent_thread_id: agent_metadata.parent_thread_id, agent_session_id: None, parent_session_id: None, agent_depth: 0, - agent_path: None, - agent_nickname: None, - agent_role: None, + agent_path: agent_metadata.agent_path, + agent_nickname: agent_metadata.agent_nickname, + agent_role: agent_metadata.agent_role, modified_at_ms, size_bytes, input_tokens, @@ -671,6 +710,16 @@ mod tests { .iter() .any(|column| column == "quota_usage_json"); assert!(has_quota_usage_json); + let has_agent_metadata_json = db + .prepare("PRAGMA table_info(session_file_rollups)") + .unwrap() + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap() + .iter() + .any(|column| column == "agent_metadata_json"); + assert!(has_agent_metadata_json); let has_quota_parser_version = db .prepare("PRAGMA table_info(session_file_rollups)") .unwrap() @@ -723,6 +772,13 @@ mod tests { rows: vec![], prompt_title: Some("First real request".to_string()), quota_usage: None, + agent_metadata: Some(SessionAgentMetadata { + thread_id: Some("child-thread".to_string()), + parent_thread_id: Some("parent-thread".to_string()), + agent_path: Some("/root/inspect_sidebar".to_string()), + agent_nickname: Some("Ada".to_string()), + agent_role: Some("explorer".to_string()), + }), }, SessionFileRollup { path: "/tmp/untitled.jsonl".to_string(), @@ -731,6 +787,7 @@ mod tests { rows: vec![], prompt_title: Some(String::new()), quota_usage: None, + agent_metadata: None, }, ], "2026-07-16T00:00:00.000Z", @@ -743,6 +800,17 @@ mod tests { sessions[0].thread_name.as_deref(), Some("First real request") ); + assert_eq!(sessions[0].thread_id.as_deref(), Some("child-thread")); + assert_eq!( + sessions[0].parent_thread_id.as_deref(), + Some("parent-thread") + ); + assert_eq!( + sessions[0].agent_path.as_deref(), + Some("/root/inspect_sidebar") + ); + assert_eq!(sessions[0].agent_nickname.as_deref(), Some("Ada")); + assert_eq!(sessions[0].agent_role.as_deref(), Some("explorer")); assert_eq!(sessions[1].thread_name, None); let _ = std::fs::remove_file(path); } @@ -788,6 +856,7 @@ mod tests { rows: vec![daily_row("2026-07-01", 1), daily_row("2026-07-02", 2)], prompt_title: None, quota_usage: None, + agent_metadata: None, }], "2026-07-02T00:00:00.000Z", ) diff --git a/src-tauri/src/scanner.rs b/src-tauri/src/scanner.rs index 8c4980e..41f1435 100644 --- a/src-tauri/src/scanner.rs +++ b/src-tauri/src/scanner.rs @@ -3,8 +3,8 @@ use crate::{ date::{date_key_in_timezone, resolve_app_timezone}, db::{ delete_missing_daily_rows, delete_missing_session_file_rollups, query_session_file_rollup, - record_scan_run, upsert_daily_rows, upsert_session_file_rollups, SessionFileRollup, - SessionQuotaRollup, + record_scan_run, upsert_daily_rows, upsert_session_file_rollups, SessionAgentMetadata, + SessionFileRollup, SessionQuotaRollup, }, pricing::{calculate_cost_usd, PricingSource}, types::{ @@ -165,6 +165,7 @@ fn load_daily_rows( .unwrap_or_default() .is_empty() || rollup.quota_usage.is_none() + || rollup.agent_metadata.is_none() { if backfill_session_metadata(&file.path, timezone, &mut rollup) { changed_rollups.push(rollup.clone()); @@ -175,7 +176,7 @@ fn load_daily_rows( } let mut events = Vec::new(); - let (prompt_title, quota_usage) = + let (prompt_title, quota_usage, agent_metadata) = load_session_file_with_quota(&file.path, &mut events, timezone)?; let rows = build_daily_rows(&events, timezone, updated_at, pricing_source); metrics.files_parsed += 1; @@ -187,6 +188,7 @@ fn load_daily_rows( rows: rows.clone(), prompt_title: Some(prompt_title), quota_usage: Some(quota_usage), + agent_metadata: Some(agent_metadata), }); all_rows.extend(rows); } @@ -248,20 +250,21 @@ fn modified_at_ms(metadata: &fs::Metadata) -> i64 { #[cfg(test)] fn load_session_file(path: &Path, events: &mut Vec) -> Result { - load_session_file_with_quota(path, events, "UTC").map(|(title, _)| title) + load_session_file_with_quota(path, events, "UTC").map(|(title, _, _)| title) } fn load_session_file_with_quota( path: &Path, events: &mut Vec, timezone: &str, -) -> Result<(String, SessionQuotaRollup), String> { +) -> Result<(String, SessionQuotaRollup, SessionAgentMetadata), String> { let content = fs::read_to_string(path).map_err(|error| error.to_string())?; let mut previous_totals: Option = None; let mut current_model: Option = None; let mut current_model_is_fallback = false; let mut current_project_path: Option = None; let mut prompt_title = None; + let mut agent_metadata = None; let mut has_turn_context = false; let mut quota_snapshots = Vec::new(); @@ -284,6 +287,9 @@ fn load_session_file_with_quota( } if entry_type == Some("session_meta") { + if agent_metadata.is_none() { + agent_metadata = Some(session_agent_metadata_from_entry(&entry)); + } current_project_path = extract_project_path(entry.get("payload").unwrap_or(&Value::Null)); continue; @@ -380,12 +386,13 @@ fn load_session_file_with_quota( Ok(( prompt_title.unwrap_or_default(), build_quota_rollup("a_snapshots, timezone), + agent_metadata.unwrap_or_default(), )) } fn backfill_session_metadata(path: &Path, timezone: &str, rollup: &mut SessionFileRollup) -> bool { match load_session_file_with_quota(path, &mut Vec::new(), timezone) { - Ok((title, quota_usage)) => { + Ok((title, quota_usage, agent_metadata)) => { if rollup .prompt_title .as_deref() @@ -397,6 +404,9 @@ fn backfill_session_metadata(path: &Path, timezone: &str, rollup: &mut SessionFi if rollup.quota_usage.is_none() { rollup.quota_usage = Some(quota_usage); } + if rollup.agent_metadata.is_none() { + rollup.agent_metadata = Some(agent_metadata); + } true } Err(error) => { @@ -615,6 +625,22 @@ fn prompt_title_from_entry(entry: &Value, has_turn_context: bool) -> Option SessionAgentMetadata { + let payload = entry.get("payload").unwrap_or(&Value::Null); + let thread_spawn = payload + .get("source") + .and_then(|source| source.get("subagent")) + .and_then(|subagent| subagent.get("thread_spawn")); + + SessionAgentMetadata { + thread_id: string_field(payload, "id"), + parent_thread_id: thread_spawn.and_then(|spawn| string_field(spawn, "parent_thread_id")), + agent_path: thread_spawn.and_then(|spawn| string_field(spawn, "agent_path")), + agent_nickname: thread_spawn.and_then(|spawn| string_field(spawn, "agent_nickname")), + agent_role: thread_spawn.and_then(|spawn| string_field(spawn, "agent_role")), + } +} + fn normalize_prompt_title(message: &str) -> Option { const MAX_CHARS: usize = 80; @@ -1108,6 +1134,37 @@ mod tests { ); } + #[test] + fn extracts_subagent_parentage_from_session_metadata() { + let entry = serde_json::json!({ + "type": "session_meta", + "payload": { + "id": "child-thread", + "source": { + "subagent": { + "thread_spawn": { + "parent_thread_id": "parent-thread", + "agent_path": "/root/inspect_sidebar", + "agent_nickname": "Ada", + "agent_role": "explorer" + } + } + } + } + }); + + assert_eq!( + session_agent_metadata_from_entry(&entry), + SessionAgentMetadata { + thread_id: Some("child-thread".to_string()), + parent_thread_id: Some("parent-thread".to_string()), + agent_path: Some("/root/inspect_sidebar".to_string()), + agent_nickname: Some("Ada".to_string()), + agent_role: Some("explorer".to_string()), + } + ); + } + #[test] fn extracts_response_item_title_after_turn_context() { let temp_dir = tempfile_dir(); @@ -1208,7 +1265,7 @@ mod tests { ) .unwrap(); db.execute( - "UPDATE session_file_rollups SET prompt_title = '', updated_at = 'legacy'", + "UPDATE session_file_rollups SET prompt_title = '', quota_usage_json = NULL, agent_metadata_json = NULL, updated_at = 'legacy'", [], ) .unwrap(); @@ -1235,6 +1292,14 @@ mod tests { ) .unwrap(); assert!(has_quota_usage); + let has_agent_metadata: bool = db + .query_row( + "SELECT agent_metadata_json IS NOT NULL FROM session_file_rollups WHERE path = ?", + [&session_path.to_string_lossy().as_ref()], + |row| row.get(0), + ) + .unwrap(); + assert!(has_agent_metadata); assert_eq!(record.rows[0].total_tokens, 1300); let unchanged = load_daily_rows( @@ -1311,6 +1376,7 @@ mod tests { rows: vec![cached_row], prompt_title: None, quota_usage: None, + agent_metadata: None, }, SessionFileRollup { path: valid_path.to_string_lossy().to_string(), @@ -1319,6 +1385,7 @@ mod tests { rows: vec![], prompt_title: None, quota_usage: None, + agent_metadata: None, }, ], "legacy", @@ -1436,7 +1503,7 @@ mod tests { ) .unwrap(); - let (_, quota) = load_session_file_with_quota(&path, &mut Vec::new(), "UTC").unwrap(); + let (_, quota, _) = load_session_file_with_quota(&path, &mut Vec::new(), "UTC").unwrap(); assert_eq!(quota.session.five_hour.len(), 1); assert_eq!(quota.session.weekly.len(), 1); diff --git a/src-tauri/src/session_replay.rs b/src-tauri/src/session_replay.rs index 6794285..e7233e7 100644 --- a/src-tauri/src/session_replay.rs +++ b/src-tauri/src/session_replay.rs @@ -2082,6 +2082,7 @@ mod tests { rows: vec![], prompt_title: Some("Replay this session".to_string()), quota_usage: None, + agent_metadata: None, }], "2026-06-01T00:00:00.000Z", ) @@ -2142,6 +2143,7 @@ mod tests { rows: vec![], prompt_title: Some(prompt_title.to_string()), quota_usage: None, + agent_metadata: None, }; upsert_session_file_rollups( &mut db, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 8b429ba..8f7e6bd 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -331,6 +331,8 @@ pub struct SessionDetailRow { pub path: String, pub session_id: String, pub thread_name: Option, + pub thread_id: Option, + pub parent_thread_id: Option, pub agent_session_id: Option, pub parent_session_id: Option, pub agent_depth: usize, diff --git a/src/components/session-titles.test.tsx b/src/components/session-titles.test.tsx index 2cf5b68..923aa87 100644 --- a/src/components/session-titles.test.tsx +++ b/src/components/session-titles.test.tsx @@ -258,7 +258,76 @@ describe("session daily usage", () => { }); describe("session titles", () => { - it("orders subagents beneath their parent and shows their hierarchy metadata", () => { + it("keeps nested subagent sessions collapsed under their main session and shows distinct agent identities", async () => { + const user = userEvent.setup(); + const onSessionClick = vi.fn(); + const main = session({ + path: "/tmp/main.jsonl", + sessionId: "main.jsonl", + threadId: "main-thread", + threadName: "Main session", + }); + const explorer = session({ + path: "/tmp/explorer.jsonl", + sessionId: "explorer.jsonl", + threadId: "explorer-thread", + parentThreadId: "main-thread", + threadName: "Inspect how titles are rendered", + agentPath: "/root/investigate_titles", + agentNickname: "Ada", + agentRole: "code_explorer", + }); + const worker = session({ + path: "/tmp/worker.jsonl", + sessionId: "worker.jsonl", + threadId: "worker-thread", + parentThreadId: "explorer-thread", + threadName: "Implement the session grouping", + agentPath: "/root/fix_sidebar", + agentNickname: "Grace", + agentRole: "worker", + }); + + render( + , + ); + + expect(i18n.t("sessions.family_cost_prefix", { lng: "en" })).toBe("All:"); + expect(i18n.t("sessions.family_cost_prefix", { lng: "zh" })).toBe("总:"); + const mainCard = screen.getByText("Main session").closest("article")!; + expect(within(mainCard).getByTestId("session-cost")).toHaveTextContent("$0.001"); + expect(within(mainCard).getByText("All:")).toBeInTheDocument(); + expect(within(mainCard).getByTestId("session-family-cost")).toHaveTextContent("$0.003"); + expect(within(mainCard).getByRole("img", { name: "Main and subagent session total cost $0.003" })).toBeInTheDocument(); + expect(screen.queryByText("investigate titles")).not.toBeInTheDocument(); + const toggle = screen.getByRole("button", { name: "Expand 2 subagent sessions under Main session" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + + await user.click(toggle); + + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("investigate titles")).toBeInTheDocument(); + expect(screen.getByText("fix sidebar")).toBeInTheDocument(); + expect(screen.getByText("Ada")).toBeInTheDocument(); + expect(screen.getByText("code explorer")).toBeInTheDocument(); + expect(screen.getAllByText("Subagent")).toHaveLength(2); + const subagentRows = screen.getAllByTestId("subagent-session-row"); + expect(subagentRows[0]).toHaveAttribute("data-agent-depth", "1"); + expect(subagentRows[1]).toHaveAttribute("data-agent-depth", "2"); + expect(within(screen.getByText("investigate titles").closest("article")!).queryByTestId("session-family-cost")).not.toBeInTheDocument(); + expect(within(screen.getByText("fix sidebar").closest("article")!).queryByTestId("session-family-cost")).not.toBeInTheDocument(); + await user.click(screen.getByText("fix sidebar").closest("article")!); + expect(onSessionClick).toHaveBeenCalledWith(worker); + + await user.click(toggle); + expect(screen.queryByText("investigate titles")).not.toBeInTheDocument(); + }); + + it("uses upstream hierarchy metadata inside collapsed session families", async () => { + const user = userEvent.setup(); render( { threadName: "Child task", agentSessionId: "child-id", parentSessionId: "root-id", - agentDepth: 1, + agentDepth: 0, agentPath: "/root/researcher", agentNickname: "researcher", agentRole: "Research", @@ -283,14 +352,19 @@ describe("session titles", () => { }), ]} />); + expect(screen.getAllByTestId("session-card")).toHaveLength(1); + expect(screen.getByTestId("session-card")).toHaveTextContent("Parent task"); + expect(screen.queryByText("Child task")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Expand 1 subagent sessions under Parent task" })); + const cards = screen.getAllByTestId("session-card"); expect(cards[0]).toHaveTextContent("Parent task"); expect(cards[1]).toHaveTextContent("Child task"); expect(cards[1].parentElement).toHaveAttribute("data-agent-depth", "1"); - expect(within(cards[1]).getByText("Subagent · researcher · Research")).toHaveAttribute( - "title", - "/root/researcher", - ); + expect(within(cards[1]).getByRole("heading", { name: "researcher" })).toBeInTheDocument(); + expect(within(cards[1]).getByText("Subagent")).toBeInTheDocument(); + expect(within(cards[1]).getByText("Research")).toBeInTheDocument(); expect(within(cards[0]).queryByText("Subagent")).not.toBeInTheDocument(); }); diff --git a/src/components/session-usage-table.tsx b/src/components/session-usage-table.tsx index 2e075dc..4f6bd8b 100644 --- a/src/components/session-usage-table.tsx +++ b/src/components/session-usage-table.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useEffect } from "react"; import { Card, CardContent } from "@/components/ui/card"; import type { SessionDetailRow } from "@/lib/api"; import { formatCompactNumber, formatCurrency, formatNumber, formatPercent } from "@/lib/formatters"; -import { Terminal, Folder, ChevronDown, Calendar, CornerDownRight } from "lucide-react"; +import { Bot, Terminal, Folder, ChevronDown, Calendar, CornerDownRight } from "lucide-react"; import { Button } from "@/components/ui/button"; import dayjs from "dayjs"; import { useTranslation } from "react-i18next"; @@ -14,6 +14,12 @@ type SessionDisplayRow = SessionDetailRow & { originalSession: SessionDetailRow; }; +type SessionFamily = { + parent: SessionDisplayRow; + children: SessionDisplayRow[]; + totalCostUSD: number; +}; + type SessionUsageTableProps = { sessions: SessionDetailRow[]; initialExpandedDate?: string | null; @@ -40,18 +46,39 @@ function cleanSessionId(sessionId: string) { return sessionId.replace(/\.jsonl$/, ""); } +function humanizeAgentValue(value: string) { + return value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); +} + +function agentSessionTitle(session: SessionDisplayRow) { + const pathName = session.agentPath?.split("/").filter(Boolean).at(-1); + return pathName ? humanizeAgentValue(pathName) : session.threadName || cleanSessionId(session.sessionId); +} + +function sessionAgentId(session: SessionDetailRow) { + return session.agentSessionId ?? session.threadId; +} + +function parentAgentId(session: SessionDetailRow) { + return session.parentSessionId ?? session.parentThreadId; +} + function orderSessionsByAgentHierarchy(sessions: SessionDisplayRow[]) { const chronological = [...sessions].sort((a, b) => b.modifiedAtMs - a.modifiedAtMs); const byAgentId = new Map( - chronological.flatMap((session) => session.agentSessionId ? [[session.agentSessionId, session] as const] : []), + chronological.flatMap((session) => { + const agentId = sessionAgentId(session); + return agentId ? [[agentId, session] as const] : []; + }), ); const children = new Map(); for (const session of chronological) { - if (!session.parentSessionId || !byAgentId.has(session.parentSessionId)) continue; - const siblings = children.get(session.parentSessionId) ?? []; + const parentId = parentAgentId(session); + if (!parentId || !byAgentId.has(parentId)) continue; + const siblings = children.get(parentId) ?? []; siblings.push(session); - children.set(session.parentSessionId, siblings); + children.set(parentId, siblings); } const ordered: SessionDisplayRow[] = []; @@ -60,17 +87,79 @@ function orderSessionsByAgentHierarchy(sessions: SessionDisplayRow[]) { if (visited.has(session)) return; visited.add(session); ordered.push(session); - if (!session.agentSessionId) return; - for (const child of children.get(session.agentSessionId) ?? []) visit(child); + const agentId = sessionAgentId(session); + if (!agentId) return; + for (const child of children.get(agentId) ?? []) visit(child); }; for (const session of chronological) { - if (!session.parentSessionId || !byAgentId.has(session.parentSessionId)) visit(session); + const parentId = parentAgentId(session); + if (!parentId || !byAgentId.has(parentId)) visit(session); } for (const session of chronological) visit(session); return ordered; } +function groupSessionFamilies( + rows: SessionDisplayRow[], + sessionsByAgentId: Map, +): SessionFamily[] { + const rowsByAgentId = new Map( + rows.flatMap((session) => { + const agentId = sessionAgentId(session); + return agentId ? [[agentId, session] as const] : []; + }), + ); + const childrenByParentPath = new Map(); + const childPaths = new Set(); + + for (const session of rows) { + let parentId = parentAgentId(session); + let visibleParent: SessionDisplayRow | undefined; + const visited = new Set(); + + while (parentId && !visited.has(parentId)) { + visited.add(parentId); + visibleParent = rowsByAgentId.get(parentId) ?? visibleParent; + const parent = sessionsByAgentId.get(parentId); + parentId = parent ? parentAgentId(parent) : undefined; + } + + if (!visibleParent || visibleParent.path === session.path) continue; + const children = childrenByParentPath.get(visibleParent.path) ?? []; + children.push(session); + childrenByParentPath.set(visibleParent.path, children); + childPaths.add(session.path); + } + + return rows + .filter((session) => !childPaths.has(session.path)) + .map((parent) => { + const children = childrenByParentPath.get(parent.path) ?? []; + return { + parent, + children, + totalCostUSD: children.reduce((sum, child) => sum + child.costUSD, parent.costUSD), + }; + }); +} + +function agentHierarchyDepth( + session: SessionDetailRow, + sessionsByAgentId: Map, +) { + let inferredDepth = 0; + let parentId = parentAgentId(session); + const visited = new Set(); + while (parentId && !visited.has(parentId)) { + visited.add(parentId); + inferredDepth += 1; + const parent = sessionsByAgentId.get(parentId); + parentId = parent ? parentAgentId(parent) : undefined; + } + return Math.min(Math.max(session.agentDepth ?? 0, inferredDepth), 6); +} + function formatDateHeader(dateStr: string) { try { return dayjs(dateStr).format("YYYY-MM-DD (dddd)"); @@ -170,6 +259,7 @@ export function SessionUsageTable({ const { t, i18n } = useTranslation(); // Track which date groups are collapsed const [collapsedDates, setCollapsedDates] = useState>({}); + const [expandedAgentGroups, setExpandedAgentGroups] = useState>({}); useEffect(() => { if (initialExpandedDate) { @@ -205,6 +295,14 @@ export function SessionUsageTable({ })); }), [sessions]); + const sessionsByAgentId = useMemo( + () => new Map(sessions.flatMap((session) => { + const agentId = sessionAgentId(session); + return agentId ? [[agentId, session] as const] : []; + })), + [sessions], + ); + // Group and sort session-day rows using the scanner's application-timezone dates. const groups = useMemo(() => { const map: Record = {}; @@ -238,6 +336,7 @@ export function SessionUsageTable({ return { date, sessions: sortedItems, + sessionFamilies: groupSessionFamilies(sortedItems, sessionsByAgentId), totalTokens, inputTokens, cachedInputTokens, @@ -249,7 +348,7 @@ export function SessionUsageTable({ projects, }; }); - }, [displaySessions, selectedProject]); + }, [displaySessions, selectedProject, sessionsByAgentId]); const filteredCount = useMemo(() => { if (!selectedProject) return displaySessions.length; @@ -271,6 +370,18 @@ export function SessionUsageTable({ ), [groups], ); + const maxFamilyCost = useMemo( + () => groups.reduce( + (maxCost, group) => group.sessionFamilies.reduce( + (familyMax, family) => family.children.length > 0 + ? Math.max(familyMax, family.totalCostUSD) + : familyMax, + maxCost, + ), + 0, + ), + [groups], + ); const toggleDate = (date: string) => { setCollapsedDates((prev) => ({ ...prev, @@ -488,7 +599,28 @@ export function SessionUsageTable({ {/* Accordion Content: compact session cards for this date */} {!collapsed && (
- {group.sessions.map((session) => { + {group.sessionFamilies.flatMap(({ parent, children, totalCostUSD }) => { + const groupKey = `${group.date}:${parent.path}`; + const expanded = expandedAgentGroups[groupKey] ?? false; + return [ + { + session: parent, + isSubagent: Boolean(parent.parentThreadId), + childCount: children.length, + familyCostUSD: children.length > 0 ? totalCostUSD : null, + groupKey, + expanded, + }, + ...(expanded ? children.map((session) => ({ + session, + isSubagent: true, + childCount: 0, + familyCostUSD: null, + groupKey, + expanded: false, + })) : []), + ]; + }).map(({ session, isSubagent, childCount, familyCostUSD, groupKey, expanded }) => { const isInactive = session.totalTokens === 0; const nonCachedInputTokens = Math.max(session.inputTokens - session.cachedInputTokens, 0); const fullTime = new Date(session.modifiedAtMs).toLocaleString(); @@ -496,7 +628,13 @@ export function SessionUsageTable({ hour: "2-digit", minute: "2-digit", }); - const title = session.threadName || cleanSessionId(session.sessionId); + const title = isSubagent + ? agentSessionTitle(session) + : session.threadName || cleanSessionId(session.sessionId); + const subagentSummary = isSubagent && session.threadName !== title + ? session.threadName + : null; + const agentRole = session.agentRole ? humanizeAgentValue(session.agentRole) : null; const shownProjects = session.projects.slice(0, 2); const shownModels = session.models.slice(0, 3); const projectOverflow = session.projects.length - shownProjects.length; @@ -504,6 +642,10 @@ export function SessionUsageTable({ const tokenRatio = sessionScale.tokens > 0 ? session.totalTokens / sessionScale.tokens : 0; const costRatio = sessionScale.cost > 0 ? session.costUSD / sessionScale.cost : 0; const cost = costTone(session.costUSD, sessionScale.cost); + const familyCostRatio = familyCostUSD !== null && maxFamilyCost > 0 + ? familyCostUSD / maxFamilyCost + : 0; + const familyCost = costTone(familyCostUSD ?? 0, maxFamilyCost); const tokenLabel = isInactive ? t("sessions.token_bar_empty") : t("sessions.token_bar_label", { @@ -520,17 +662,19 @@ export function SessionUsageTable({ cost: formatCurrency(session.costUSD), percent: formatPercent(costRatio), }); - const isSubagent = Boolean(session.parentSessionId) || (session.agentDepth ?? 0) > 0; - const hierarchyDepth = Math.min(session.agentDepth ?? (isSubagent ? 1 : 0), 6); - const subagentLabel = [t("sessions.subagent"), session.agentNickname, session.agentRole] - .filter(Boolean) - .join(" · "); + const familyCostLabel = familyCostUSD !== null + ? t("sessions.family_cost_pill_label", { cost: formatCurrency(familyCostUSD) }) + : ""; + const hierarchyDepth = isSubagent + ? agentHierarchyDepth(session, sessionsByAgentId) + : 0; return (
{isSubagent ? ( @@ -554,20 +698,30 @@ export function SessionUsageTable({ onSessionClick(session.originalSession); } }} - className={`session-usage-card rounded-lg border border-border/50 bg-card/70 px-3 py-2.5 shadow-sm transition-colors duration-150 hover:border-primary/35 hover:bg-card ${onSessionClick ? "cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/70" : ""}`} + className={`session-usage-card rounded-lg border px-3 py-2.5 shadow-sm transition-colors duration-150 hover:border-primary/35 hover:bg-card ${isSubagent ? "border-primary/20 bg-primary/[0.035]" : "border-border/50 bg-card/70"} ${onSessionClick ? "cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/70" : ""}`} >
+ {isSubagent ? : null}

{title}

{isSubagent ? ( - - {subagentLabel} + + {t("sessions.subagent")} ) : null}
+ {subagentSummary ? ( +

+ {subagentSummary} +

+ ) : null} + {isSubagent && (session.agentNickname || agentRole) ? ( +
+ {session.agentNickname ? {session.agentNickname} : null} + {session.agentNickname && agentRole ? : null} + {agentRole ? {agentRole} : null} +
+ ) : null}
{shownProjects.length > 0 ? ( @@ -652,6 +806,30 @@ export function SessionUsageTable({ ) : null} {formatCurrency(session.costUSD)} + {familyCostUSD !== null ? ( + <> + + + {t("sessions.family_cost_prefix")} + + {familyCostRatio > 0 ? ( + + + + ) : null}
{isInactive ? null : ( @@ -669,6 +847,22 @@ export function SessionUsageTable({
+ {childCount > 0 ? ( + + ) : null}
); })} diff --git a/src/lib/api.ts b/src/lib/api.ts index b26d48d..5f21aa1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -268,6 +268,8 @@ export type SessionDetailRow = { path: string; sessionId: string; threadName: string | null; + threadId?: string | null; + parentThreadId?: string | null; agentSessionId?: string | null; parentSessionId?: string | null; agentDepth?: number; diff --git a/src/locales/en.json b/src/locales/en.json index fbd1345..9779b9e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -438,9 +438,15 @@ "token_bar_empty": "No token activity", "token_total_label": "Total tokens {{total}}; {{percent}} of the highest visible session", "cost_pill_label": "Cost {{cost}}; {{percent}} of the highest visible session", - "subagent": "Subagent", - "open_session": "Open session {{title}}", - "quota": { + "family_cost_prefix": "All:", + "family_cost_pill_label": "Main and subagent session total cost {{cost}}", + "open_session": "Open session {{title}}", + "subagent": "Subagent", + "subagent_sessions_one": "1 subagent session", + "subagent_sessions_other": "{{count}} subagent sessions", + "expand_subagents": "Expand {{count}} subagent sessions under {{title}}", + "collapse_subagents": "Collapse {{count}} subagent sessions under {{title}}", + "quota": { "title": "Observed quota usage", "estimated": "Estimated", "five_hour": "5h", diff --git a/src/locales/zh.json b/src/locales/zh.json index 687a99e..5e9c973 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -438,9 +438,15 @@ "token_bar_empty": "无 Token 活动", "token_total_label": "Token 总数 {{total}};相当于当前可见会话最高值的 {{percent}}", "cost_pill_label": "花费 {{cost}};相当于当前可见会话最高值的 {{percent}}", - "subagent": "子 Agent", - "open_session": "打开会话 {{title}}", - "quota": { + "family_cost_prefix": "总:", + "family_cost_pill_label": "主会话及其所有子会话总花费 {{cost}}", + "open_session": "打开会话 {{title}}", + "subagent": "子智能体", + "subagent_sessions_one": "1 个子智能体会话", + "subagent_sessions_other": "{{count}} 个子智能体会话", + "expand_subagents": "展开主会话“{{title}}”下的 {{count}} 个子智能体会话", + "collapse_subagents": "收起主会话“{{title}}”下的 {{count}} 个子智能体会话", + "quota": { "title": "观测到的限额消耗", "estimated": "估算", "five_hour": "5h",