Files
CC-Switch/src/components/sessions/utils.ts
T
ayxwi 6716a4c408 [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>
2026-06-07 19:23:24 +08:00

176 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ?? ""}`;
export const getBaseName = (value?: string | null) => {
if (!value) return "";
const trimmed = value.trim();
if (!trimmed) return "";
const normalized = trimmed.replace(/[\\/]+$/, "");
const parts = normalized.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || trimmed;
};
export const formatTimestamp = (value?: number) => {
if (!value) return "";
return new Date(value).toLocaleString();
};
export const formatRelativeTime = (
value: number | undefined,
t: (key: string, options?: Record<string, unknown>) => string,
) => {
if (!value) return "";
const now = Date.now();
const diff = now - value;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return t("sessionManager.justNow");
if (minutes < 60) return t("sessionManager.minutesAgo", { count: minutes });
if (hours < 24) return t("sessionManager.hoursAgo", { count: hours });
if (days < 7) return t("sessionManager.daysAgo", { count: days });
return new Date(value).toLocaleDateString();
};
export const getProviderLabel = (
providerId: string,
t: (key: string) => string,
) => {
const key = `apps.${providerId}`;
const translated = t(key);
return translated === key ? providerId : translated;
};
// 根据 providerId 获取对应的图标名称
export const getProviderIconName = (providerId: string) => {
if (providerId === "codex") return "openai";
if (providerId === "claude") return "claude";
if (providerId === "opencode") return "opencode";
if (providerId === "openclaw") return "openclaw";
return providerId;
};
export const getRoleTone = (role: string) => {
const normalized = role.toLowerCase();
if (normalized === "assistant") return "text-blue-500";
if (normalized === "user") return "text-emerald-500";
if (normalized === "system") return "text-amber-500";
if (normalized === "tool") return "text-purple-500";
return "text-muted-foreground";
};
export const getRoleLabel = (role: string, t: (key: string) => string) => {
const normalized = role.toLowerCase();
if (normalized === "assistant") return "AI";
if (normalized === "user") return t("sessionManager.roleUser");
if (normalized === "system") return t("sessionManager.roleSystem");
if (normalized === "tool") return t("sessionManager.roleTool");
return role;
};
export const formatSessionTitle = (session: SessionMeta) => {
return (
session.title ||
getBaseName(session.projectDir) ||
session.sessionId.slice(0, 8)
);
};
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, "\\$&");
const parts = text.split(new RegExp(`(${escaped})`, "gi"));
if (parts.length === 1) return text;
return parts.map((part, i) =>
i % 2 === 1
? createElement(
"mark",
{
key: i,
className:
"bg-yellow-200/60 dark:bg-yellow-500/30 text-inherit rounded-sm px-0.5",
},
part,
)
: part,
);
};