From 3c4db81825f924d59afcc3472608ccd369e450ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=9E=97=E5=85=88=E7=94=9F?= <449066740@qq.com> Date: Fri, 21 Aug 2026 12:48:44 +0800 Subject: [PATCH 1/2] Terminal feat: nest subagent sessions under parent threads --- src-tauri/src/db.rs | 80 ++++++++++++++- src-tauri/src/scanner.rs | 87 ++++++++++++++-- src-tauri/src/session_replay.rs | 1 + src-tauri/src/types.rs | 5 + src/components/session-titles.test.tsx | 57 +++++++++++ src/components/session-usage-table.tsx | 133 +++++++++++++++++++++++-- src/lib/api.ts | 5 + src/locales/en.json | 5 + src/locales/zh.json | 5 + 9 files changed, 358 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index f9f9ad5..f2b2de2 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -41,6 +41,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, updated_at TEXT NOT NULL ); "#, @@ -64,9 +65,25 @@ 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", + )?; 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, @@ -75,6 +92,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)] @@ -234,7 +252,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 = ? "#, @@ -244,16 +262,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, @@ -261,6 +283,7 @@ pub fn query_session_file_rollup( rows, prompt_title, quota_usage, + agent_metadata, })) } Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), @@ -317,14 +340,16 @@ pub fn upsert_session_file_rollups( rows_json, prompt_title, quota_usage_json, + agent_metadata_json, 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, updated_at = excluded.updated_at "#, ) @@ -339,6 +364,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, @@ -347,6 +378,7 @@ pub fn upsert_session_file_rollups( rows_json, rollup.prompt_title, quota_usage_json, + agent_metadata_json, updated_at ]) .map_err(|error| error.to_string())?; @@ -460,7 +492,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 "#, @@ -477,6 +510,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(); @@ -531,6 +568,11 @@ 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_path: agent_metadata.agent_path, + agent_nickname: agent_metadata.agent_nickname, + agent_role: agent_metadata.agent_role, modified_at_ms, size_bytes, input_tokens, @@ -623,6 +665,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 _ = std::fs::remove_file(path); } @@ -643,6 +695,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(), @@ -651,6 +710,7 @@ mod tests { rows: vec![], prompt_title: Some(String::new()), quota_usage: None, + agent_metadata: None, }, ], "2026-07-16T00:00:00.000Z", @@ -663,6 +723,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); } @@ -708,6 +779,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 5ef0a77..8427951 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::{ @@ -159,7 +159,10 @@ fn load_daily_rows( query_session_file_rollup(db, &file.cache_key, file.modified_at_ms, file.size_bytes)? { metrics.files_reused += 1; - if rollup.prompt_title.is_none() || rollup.quota_usage.is_none() { + if rollup.prompt_title.is_none() + || rollup.quota_usage.is_none() + || rollup.agent_metadata.is_none() + { if backfill_session_metadata(&file.path, timezone, &mut rollup) { changed_rollups.push(rollup.clone()); } @@ -169,7 +172,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; @@ -181,6 +184,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); } @@ -242,20 +246,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 quota_snapshots = Vec::new(); for line in content.lines() { @@ -274,6 +279,9 @@ fn load_session_file_with_quota( let entry_type = entry.get("type").and_then(Value::as_str); 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; @@ -370,18 +378,22 @@ 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.is_none() { rollup.prompt_title = Some(title); } 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) => { @@ -570,6 +582,22 @@ fn prompt_title_from_entry(entry: &Value) -> Option { }) } +fn session_agent_metadata_from_entry(entry: &Value) -> 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; @@ -1063,6 +1091,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 normalizes_truncates_and_marks_missing_prompt_titles() { assert_eq!( @@ -1129,7 +1188,7 @@ mod tests { ) .unwrap(); db.execute( - "UPDATE session_file_rollups SET prompt_title = NULL, quota_usage_json = NULL, updated_at = 'legacy'", + "UPDATE session_file_rollups SET prompt_title = NULL, quota_usage_json = NULL, agent_metadata_json = NULL, updated_at = 'legacy'", [], ) .unwrap(); @@ -1156,6 +1215,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( @@ -1232,6 +1299,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(), @@ -1240,6 +1308,7 @@ mod tests { rows: vec![], prompt_title: None, quota_usage: None, + agent_metadata: None, }, ], "legacy", @@ -1357,7 +1426,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 2bb48a2..60cc446 100644 --- a/src-tauri/src/session_replay.rs +++ b/src-tauri/src/session_replay.rs @@ -1978,6 +1978,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", ) diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index a037d44..c78c384 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -329,6 +329,11 @@ 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_path: Option, + pub agent_nickname: Option, + pub agent_role: Option, pub modified_at_ms: i64, pub size_bytes: i64, pub input_tokens: i64, diff --git a/src/components/session-titles.test.tsx b/src/components/session-titles.test.tsx index 772f374..adef48f 100644 --- a/src/components/session-titles.test.tsx +++ b/src/components/session-titles.test.tsx @@ -188,6 +188,63 @@ describe("session daily usage", () => { }); describe("session titles", () => { + 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(screen.getByText("Main session")).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); + 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("shows the summary name with weak file metadata and avoids repeating a fallback ID", () => { render( , +): SessionFamily[] { + const rowsByThreadId = new Map( + rows.flatMap((session) => session.threadId ? [[session.threadId, session] as const] : []), + ); + const childrenByParentPath = new Map(); + const childPaths = new Set(); + + for (const session of rows) { + let parentThreadId = session.parentThreadId; + let visibleParent: SessionDisplayRow | undefined; + const visited = new Set(); + + while (parentThreadId && !visited.has(parentThreadId)) { + visited.add(parentThreadId); + visibleParent = rowsByThreadId.get(parentThreadId) ?? visibleParent; + parentThreadId = sessionsByThreadId.get(parentThreadId)?.parentThreadId; + } + + 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) => ({ parent, children: childrenByParentPath.get(parent.path) ?? [] })); +} + function formatDateHeader(dateStr: string) { try { return dayjs(dateStr).format("YYYY-MM-DD (dddd)"); @@ -83,6 +130,7 @@ export function SessionUsageTable({ const { t } = useTranslation(); // Track which date groups are collapsed const [collapsedDates, setCollapsedDates] = useState>({}); + const [expandedAgentGroups, setExpandedAgentGroups] = useState>({}); useEffect(() => { if (initialExpandedDate) { @@ -118,6 +166,11 @@ export function SessionUsageTable({ })); }), [sessions]); + const sessionsByThreadId = useMemo( + () => new Map(sessions.flatMap((session) => session.threadId ? [[session.threadId, session] as const] : [])), + [sessions], + ); + // Group and sort session-day rows using the scanner's application-timezone dates. const groups = useMemo(() => { const map: Record = {}; @@ -149,6 +202,7 @@ export function SessionUsageTable({ return { date, sessions: sortedItems, + sessionFamilies: groupSessionFamilies(sortedItems, sessionsByThreadId), totalTokens, inputTokens, cachedInputTokens, @@ -158,7 +212,7 @@ export function SessionUsageTable({ projects, }; }); - }, [displaySessions, selectedProject]); + }, [displaySessions, selectedProject, sessionsByThreadId]); const filteredCount = useMemo(() => { if (!selectedProject) return displaySessions.length; @@ -367,7 +421,26 @@ export function SessionUsageTable({ {/* Accordion Content: compact session cards for this date */} {!collapsed && (
- {group.sessions.map((session) => { + {group.sessionFamilies.flatMap(({ parent, children }) => { + const groupKey = `${group.date}:${parent.path}`; + const expanded = expandedAgentGroups[groupKey] ?? false; + return [ + { + session: parent, + isSubagent: Boolean(parent.parentThreadId), + childCount: children.length, + groupKey, + expanded, + }, + ...(expanded ? children.map((session) => ({ + session, + isSubagent: true, + childCount: 0, + groupKey, + expanded: false, + })) : []), + ]; + }).map(({ session, isSubagent, childCount, groupKey, expanded }) => { const isInactive = session.totalTokens === 0; const nonCachedInputTokens = Math.max(session.inputTokens - session.cachedInputTokens, 0); const cacheHitRate = session.inputTokens > 0 ? session.cachedInputTokens / session.inputTokens : 0; @@ -376,7 +449,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; @@ -402,8 +481,12 @@ export function SessionUsageTable({ }); return ( -
+
{formattedTime}
@@ -427,9 +510,28 @@ export function SessionUsageTable({
- + {isSubagent + ? + : }

{title}

+ {isSubagent ? ( + + {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 ? shownProjects.map((project) => ( @@ -518,6 +620,23 @@ export function SessionUsageTable({
+ {childCount > 0 ? ( + + ) : null} +
); })} diff --git a/src/lib/api.ts b/src/lib/api.ts index e839cf8..c7f075e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -266,6 +266,11 @@ export type SessionDetailRow = { path: string; sessionId: string; threadName: string | null; + threadId?: string | null; + parentThreadId?: string | null; + agentPath?: string | null; + agentNickname?: string | null; + agentRole?: string | null; modifiedAtMs: number; sizeBytes: number; inputTokens: number; diff --git a/src/locales/en.json b/src/locales/en.json index 0615eab..305bbef 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -438,6 +438,11 @@ "token_total_label": "Total tokens {{total}}; {{percent}} of the highest visible session", "cost_pill_label": "Cost {{cost}}; {{percent}} of the highest visible session", "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", diff --git a/src/locales/zh.json b/src/locales/zh.json index 71467bd..f082b92 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -438,6 +438,11 @@ "token_total_label": "Token 总数 {{total}};相当于当前可见会话最高值的 {{percent}}", "cost_pill_label": "花费 {{cost}};相当于当前可见会话最高值的 {{percent}}", "open_session": "打开会话 {{title}}", + "subagent": "子智能体", + "subagent_sessions_one": "1 个子智能体会话", + "subagent_sessions_other": "{{count}} 个子智能体会话", + "expand_subagents": "展开主会话“{{title}}”下的 {{count}} 个子智能体会话", + "collapse_subagents": "收起主会话“{{title}}”下的 {{count}} 个子智能体会话", "quota": { "title": "观测到的限额消耗", "estimated": "估算", From 6011787a3c54d39b346a457bd4f06b432529bbc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=9E=97=E5=85=88=E7=94=9F?= <449066740@qq.com> Date: Sat, 22 Aug 2026 16:26:57 +0800 Subject: [PATCH 2/2] feat(sessions): show parent family total cost --- src/components/session-titles.test.tsx | 10 ++++- src/components/session-usage-table.tsx | 59 ++++++++++++++++++++++++-- src/locales/en.json | 2 + src/locales/zh.json | 2 + 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/components/session-titles.test.tsx b/src/components/session-titles.test.tsx index ad07e30..e48d348 100644 --- a/src/components/session-titles.test.tsx +++ b/src/components/session-titles.test.tsx @@ -231,7 +231,13 @@ describe("session titles", () => { />, ); - expect(screen.getByText("Main session")).toBeInTheDocument(); + 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"); @@ -244,6 +250,8 @@ describe("session titles", () => { expect(screen.getByText("Ada")).toBeInTheDocument(); expect(screen.getByText("code explorer")).toBeInTheDocument(); expect(screen.getAllByText("Subagent")).toHaveLength(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); diff --git a/src/components/session-usage-table.tsx b/src/components/session-usage-table.tsx index 1c3c485..1bf2538 100644 --- a/src/components/session-usage-table.tsx +++ b/src/components/session-usage-table.tsx @@ -17,6 +17,7 @@ type SessionDisplayRow = SessionDetailRow & { type SessionFamily = { parent: SessionDisplayRow; children: SessionDisplayRow[]; + totalCostUSD: number; }; type SessionUsageTableProps = { @@ -84,7 +85,14 @@ function groupSessionFamilies( return rows .filter((session) => !childPaths.has(session.path)) - .map((parent) => ({ parent, children: childrenByParentPath.get(parent.path) ?? [] })); + .map((parent) => { + const children = childrenByParentPath.get(parent.path) ?? []; + return { + parent, + children, + totalCostUSD: children.reduce((sum, child) => sum + child.costUSD, parent.costUSD), + }; + }); } function formatDateHeader(dateStr: string) { @@ -240,6 +248,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, @@ -426,7 +446,7 @@ export function SessionUsageTable({ {/* Accordion Content: compact session cards for this date */} {!collapsed && (
- {group.sessionFamilies.flatMap(({ parent, children }) => { + {group.sessionFamilies.flatMap(({ parent, children, totalCostUSD }) => { const groupKey = `${group.date}:${parent.path}`; const expanded = expandedAgentGroups[groupKey] ?? false; return [ @@ -434,6 +454,7 @@ export function SessionUsageTable({ session: parent, isSubagent: Boolean(parent.parentThreadId), childCount: children.length, + familyCostUSD: children.length > 0 ? totalCostUSD : null, groupKey, expanded, }, @@ -441,11 +462,12 @@ export function SessionUsageTable({ session, isSubagent: true, childCount: 0, + familyCostUSD: null, groupKey, expanded: false, })) : []), ]; - }).map(({ session, isSubagent, childCount, groupKey, expanded }) => { + }).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(); @@ -467,6 +489,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", { @@ -483,6 +509,9 @@ export function SessionUsageTable({ cost: formatCurrency(session.costUSD), percent: formatPercent(costRatio), }); + const familyCostLabel = familyCostUSD !== null + ? t("sessions.family_cost_pill_label", { cost: formatCurrency(familyCostUSD) }) + : ""; return (
{formatCurrency(session.costUSD)} + {familyCostUSD !== null ? ( + <> + + + {t("sessions.family_cost_prefix")} + + {familyCostRatio > 0 ? ( + + + + ) : null}
{isInactive ? null : ( diff --git a/src/locales/en.json b/src/locales/en.json index c2189cb..a3eda1f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -437,6 +437,8 @@ "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", + "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", diff --git a/src/locales/zh.json b/src/locales/zh.json index 72923bb..f2af668 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -437,6 +437,8 @@ "token_bar_empty": "无 Token 活动", "token_total_label": "Token 总数 {{total}};相当于当前可见会话最高值的 {{percent}}", "cost_pill_label": "花费 {{cost}};相当于当前可见会话最高值的 {{percent}}", + "family_cost_prefix": "总:", + "family_cost_pill_label": "主会话及其所有子会话总花费 {{cost}}", "open_session": "打开会话 {{title}}", "subagent": "子智能体", "subagent_sessions_one": "1 个子智能体会话",