mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
[codex] Fix VS Code session previews (#3593)
* Fix Codex VS Code session previews * fix(codex): use last IDE request heading for session previews A markdown heading inside the active selection / open file could precede the real injected request, so matching the first "## My request for Codex:" heading picked selection content instead of the user prompt. Scan for the last matching heading (the IDE injects the real request as the final section) on both the Rust title path and the frontend TOC preview path. Add regression tests for the selection-heading case, and pin the known best-effort limitation when the request body itself repeats the heading. --------- Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
@@ -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<Regex> = 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<SessionMeta> {
|
||||
&& 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("<environment_context>")
|
||||
{
|
||||
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<String> {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.starts_with("# AGENTS.md")
|
||||
|| trimmed.starts_with("<environment_context>")
|
||||
{
|
||||
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<String> {
|
||||
let normalized = text.replace("\r\n", "\n");
|
||||
let lines = normalized.lines().collect::<Vec<_>>();
|
||||
|
||||
// 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<String> = 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<String> {
|
||||
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");
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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("<environment_context>") ||
|
||||
(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, "\\$&");
|
||||
|
||||
@@ -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(
|
||||
"<environment_context>\n<cwd>F:/project</cwd>",
|
||||
),
|
||||
).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)}...`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user