diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs index 5d8a99264..616a30458 100644 --- a/src-tauri/src/session_manager/providers/codex.rs +++ b/src-tauri/src/session_manager/providers/codex.rs @@ -15,6 +15,8 @@ use super::utils::{ }; const PROVIDER_ID: &str = "codex"; +const VSCODE_CONTEXT_PREFIX: &str = "# Context from my IDE setup:"; +const CODEX_REQUEST_MARKER: &str = "my request for codex"; static UUID_RE: LazyLock = LazyLock::new(|| { Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") @@ -186,12 +188,8 @@ fn parse_session(path: &Path) -> Option { && payload.get("role").and_then(Value::as_str) == Some("user") { let text = payload.get("content").map(extract_text).unwrap_or_default(); - let trimmed = text.trim(); - if !trimmed.is_empty() - && !trimmed.starts_with("# AGENTS.md") - && !trimmed.starts_with("") - { - first_user_message = Some(trimmed.to_string()); + if let Some(title) = title_candidate_from_user_message(&text) { + first_user_message = Some(title); } } } @@ -266,6 +264,80 @@ fn is_subagent_source(source: Option<&Value>) -> bool { .unwrap_or(false) } +fn title_candidate_from_user_message(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed.is_empty() + || trimmed.starts_with("# AGENTS.md") + || trimmed.starts_with("") + { + return None; + } + + if trimmed.starts_with(VSCODE_CONTEXT_PREFIX) { + return extract_codex_prompt_from_ide_context(trimmed); + } + + Some(trimmed.to_string()) +} + +fn extract_codex_prompt_from_ide_context(text: &str) -> Option { + let normalized = text.replace("\r\n", "\n"); + let lines = normalized.lines().collect::>(); + + // VS Code injects the real prompt as the LAST "## My request for Codex:" + // section, so keep the final matching heading. Earlier matches can be + // headings that live inside the active selection / open file content. + // Trade-off: if the request body itself repeats the heading, the title + // truncates to its trailing part (rare; covered by tests below). + let mut prompt: Option = None; + for (index, line) in lines.iter().enumerate() { + let Some(inline_prompt) = codex_request_heading_payload(line) else { + continue; + }; + + if !inline_prompt.is_empty() { + prompt = Some(inline_prompt.to_string()); + continue; + } + + let following_prompt = lines[index + 1..].join("\n").trim().to_string(); + prompt = (!following_prompt.is_empty()).then_some(following_prompt); + } + + prompt +} + +fn codex_request_heading_payload(line: &str) -> Option<&str> { + let trimmed = line.trim(); + if !trimmed.starts_with('#') { + return None; + } + + let heading = trimmed.trim_start_matches('#').trim_start(); + let lowered = heading.to_ascii_lowercase(); + if !lowered.starts_with(CODEX_REQUEST_MARKER) { + return None; + } + + let suffix = heading[CODEX_REQUEST_MARKER.len()..].trim_start(); + if suffix.is_empty() { + return Some(""); + } + + let Some(separator) = suffix.chars().next() else { + return Some(""); + }; + if !matches!(separator, ':' | ':' | '-' | '—') { + return None; + } + + Some( + suffix + .trim_start_matches(|c: char| c.is_whitespace() || matches!(c, ':' | ':' | '-' | '—')) + .trim(), + ) +} + fn infer_session_id_from_filename(path: &Path) -> Option { let file_name = path.file_name()?.to_string_lossy(); UUID_RE.find(&file_name).map(|mat| mat.as_str().to_string()) @@ -426,6 +498,114 @@ mod tests { assert_eq!(meta.title.as_deref(), Some("Fix the login bug")); } + #[test] + fn parse_session_extracts_vscode_ide_request_as_title() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active file: src/main.ts\\n\\n## My request for Codex:\\nFix the session title preview\"}}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("Fix the session title preview")); + } + + #[test] + fn parse_session_extracts_inline_vscode_ide_request_as_title() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## My request for Codex: Fix the TOC preview\"}}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("Fix the TOC preview")); + } + + #[test] + fn parse_session_ignores_marker_mentions_before_request_heading() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active selection:\\nMy request for Codex: not the prompt\\n\\n## My request for Codex:\\nUse the real request heading\"}}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("Use the real request heading")); + } + + #[test] + fn parse_session_uses_last_request_heading_when_selection_has_one() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active selection: docs/codex-format.md\\n## My request for Codex:\\nselected document content, not the real request\\n\\n## My request for Codex:\\nUse the last request heading\"}}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("Use the last request heading")); + } + + // Known limitation: the IDE marker is matched purely by text, so a + // "## My request for Codex:" line inside the real request body is treated as + // a new boundary and only the trailing part is kept. This pins the + // best-effort behavior; fully fixing it needs structured IDE section data + // that the Codex VS Code context does not provide. + #[test] + fn parse_session_keeps_trailing_part_when_request_body_repeats_heading() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active file: foo.ts\\n\\n## My request for Codex:\\nDocument the format, for example:\\n## My request for Codex:\\nand the rest follows.\"}}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("and the rest follows.")); + } + + #[test] + fn parse_session_skips_vscode_ide_context_without_request() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active file: src/main.ts\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Fix the login bug\"}}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("Fix the login bug")); + } + #[test] fn parse_session_falls_back_to_dir_basename() { let temp = tempdir().expect("tempdir"); diff --git a/src/components/sessions/SessionManagerPage.tsx b/src/components/sessions/SessionManagerPage.tsx index bb64257f4..2ab97a6f3 100644 --- a/src/components/sessions/SessionManagerPage.tsx +++ b/src/components/sessions/SessionManagerPage.tsx @@ -48,12 +48,15 @@ import { SessionItem } from "./SessionItem"; import { SessionMessageItem } from "./SessionMessageItem"; import { SessionTocDialog, SessionTocSidebar } from "./SessionToc"; import { + extractCodexPromptPreview, + formatSessionMessagePreview, formatSessionTitle, formatTimestamp, getBaseName, getProviderIconName, getProviderLabel, getSessionKey, + shouldHideCodexMessageFromToc, } from "./utils"; type ProviderFilter = @@ -167,18 +170,28 @@ export function SessionManagerPage({ appId }: { appId: string }) { }); }, [sessions]); + const isCodexSession = selectedSession?.providerId === "codex"; + // 提取用户消息用于目录 const userMessagesToc = useMemo(() => { return messages .map((msg, index) => ({ msg, index })) - .filter(({ msg }) => msg.role.toLowerCase() === "user") - .map(({ msg, index }) => ({ - index, - preview: - msg.content.slice(0, 50) + (msg.content.length > 50 ? "..." : ""), - ts: msg.ts, - })); - }, [messages]); + .filter(({ msg }) => { + if (msg.role.toLowerCase() !== "user") return false; + return !(isCodexSession && shouldHideCodexMessageFromToc(msg.content)); + }) + .map(({ msg, index }) => { + const previewContent = isCodexSession + ? extractCodexPromptPreview(msg.content) + : msg.content; + + return { + index, + preview: formatSessionMessagePreview(previewContent), + ts: msg.ts, + }; + }); + }, [isCodexSession, messages]); const scrollToMessage = (index: number) => { virtualizer.scrollToIndex(index, { align: "center", behavior: "smooth" }); diff --git a/src/components/sessions/utils.ts b/src/components/sessions/utils.ts index 1c2c677a9..7bba10e9f 100644 --- a/src/components/sessions/utils.ts +++ b/src/components/sessions/utils.ts @@ -2,6 +2,56 @@ import type { ReactNode } from "react"; import { createElement } from "react"; import { SessionMeta } from "@/types"; +const CODEX_IDE_CONTEXT_PREFIX = "# Context from my IDE setup:"; +const CODEX_REQUEST_MARKER = "my request for codex"; + +const getCodexRequestHeadingPayload = (lineText: string) => { + if (!lineText.startsWith("#")) return null; + + const heading = lineText.replace(/^#+\s*/, ""); + const suffix = heading.toLowerCase().startsWith(CODEX_REQUEST_MARKER) + ? heading.slice(CODEX_REQUEST_MARKER.length).trimStart() + : null; + + if (suffix === null) return null; + if (!suffix) return ""; + if (!/^[::\-—]/.test(suffix)) return null; + + return suffix.replace(/^[::\-—\s]+/, "").trim(); +}; + +const extractCodexPromptFromIdeContext = (content: string) => { + const trimmed = content.trim(); + if (!trimmed.startsWith(CODEX_IDE_CONTEXT_PREFIX)) { + return null; + } + + // VS Code injects the real prompt as the LAST "## My request for Codex:" + // section, so keep the final matching heading. Earlier matches can be + // headings that live inside the active selection / open file content. + // Trade-off: if the request body itself repeats the heading, the preview + // truncates to its trailing part (rare; see sessionUtils.test.ts). + const lines = trimmed.replace(/\r\n/g, "\n").split("\n"); + let prompt: string | null = null; + for (const [index, line] of lines.entries()) { + const inlinePrompt = getCodexRequestHeadingPayload(line.trim()); + if (inlinePrompt === null) continue; + + if (inlinePrompt) { + prompt = inlinePrompt; + continue; + } + + const followingPrompt = lines + .slice(index + 1) + .join("\n") + .trim(); + prompt = followingPrompt || null; + } + + return prompt; +}; + export const getSessionKey = (session: SessionMeta) => `${session.providerId}:${session.sessionId}:${session.sourcePath ?? ""}`; @@ -81,6 +131,29 @@ export const formatSessionTitle = (session: SessionMeta) => { ); }; +export const shouldHideCodexMessageFromToc = (content: string) => { + const trimmed = content.trim(); + return ( + trimmed.startsWith("# AGENTS.md instructions for ") || + trimmed.startsWith("") || + (trimmed.startsWith(CODEX_IDE_CONTEXT_PREFIX) && + !extractCodexPromptFromIdeContext(trimmed)) + ); +}; + +export const extractCodexPromptPreview = (content: string) => { + return extractCodexPromptFromIdeContext(content) ?? content; +}; + +export const formatSessionMessagePreview = ( + content: string, + maxLength = 50, +) => { + return ( + content.slice(0, maxLength) + (content.length > maxLength ? "..." : "") + ); +}; + export const highlightText = (text: string, query: string): ReactNode => { if (!query) return text; const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); diff --git a/tests/components/sessionUtils.test.ts b/tests/components/sessionUtils.test.ts new file mode 100644 index 000000000..cbf02c142 --- /dev/null +++ b/tests/components/sessionUtils.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { + extractCodexPromptPreview, + formatSessionMessagePreview, + shouldHideCodexMessageFromToc, +} from "@/components/sessions/utils"; + +describe("session utils", () => { + it("extracts Codex VS Code prompts after the request marker", () => { + const content = [ + "# Context from my IDE setup:", + "", + "## Active file: src/main.ts", + "", + "## My request for Codex:", + "Fix the session title preview", + ].join("\n"); + + expect(extractCodexPromptPreview(content)).toBe( + "Fix the session title preview", + ); + }); + + it("extracts inline Codex VS Code prompts", () => { + const content = [ + "# Context from my IDE setup:", + "", + "## My request for Codex: Fix the TOC preview", + ].join("\n"); + + expect(extractCodexPromptPreview(content)).toBe("Fix the TOC preview"); + }); + + it("ignores marker mentions before the Codex request heading", () => { + const content = [ + "# Context from my IDE setup:", + "", + "## Active selection:", + "My request for Codex: not the prompt", + "", + "## My request for Codex:", + "Use the real request heading", + ].join("\n"); + + expect(extractCodexPromptPreview(content)).toBe( + "Use the real request heading", + ); + }); + + it("uses the last request heading when the selection contains one", () => { + const content = [ + "# Context from my IDE setup:", + "", + "## Active selection: docs/codex-format.md:10-14", + "## My request for Codex:", + "selected document content, not the real request", + "", + "## My request for Codex:", + "the real injected request", + ].join("\n"); + + expect(extractCodexPromptPreview(content)).toBe( + "the real injected request", + ); + }); + + // Known limitation: the IDE marker is matched purely by text, so a + // "## My request for Codex:" line inside the real request body is treated as + // a new boundary and only the trailing part is kept. Pinning this documents + // the best-effort behavior; fully fixing it needs structured IDE section data + // that the Codex VS Code context does not provide. + it("keeps only the trailing part when the request body repeats the heading", () => { + const content = [ + "# Context from my IDE setup:", + "", + "## Active file: foo.ts", + "", + "## My request for Codex:", + "Document the format, for example:", + "## My request for Codex:", + "and the rest follows.", + ].join("\n"); + + expect(extractCodexPromptPreview(content)).toBe("and the rest follows."); + }); + + it("does not extract from ordinary messages that mention the marker", () => { + const content = "Please explain the phrase My request for Codex."; + + expect(extractCodexPromptPreview(content)).toBe(content); + }); + + it("hides Codex context messages without user prompts from the TOC", () => { + expect( + shouldHideCodexMessageFromToc("# AGENTS.md instructions for F:/project"), + ).toBe(true); + expect( + shouldHideCodexMessageFromToc( + "\nF:/project", + ), + ).toBe(true); + expect(shouldHideCodexMessageFromToc("# Context from my IDE setup:")).toBe( + true, + ); + expect( + shouldHideCodexMessageFromToc( + "# Context from my IDE setup:\n\n## My request for Codex:\nFix it", + ), + ).toBe(false); + }); + + it("formats message previews with truncation", () => { + expect(formatSessionMessagePreview("short message")).toBe("short message"); + expect(formatSessionMessagePreview("a".repeat(51))).toBe( + `${"a".repeat(50)}...`, + ); + }); +});