Files
CC-Switch/src/components/sessions/utils.ts
T
Nothing 251b13e5d2 feat(sessions): 新增会话分类视图与分组管理 (#4776)
* feat(sessions): 新增会话分类查看方式

* test(sessions): 补充会话分类视图覆盖

* feat(sessions): 适配分类视图批量管理

* test(sessions): 补充分类批量选择覆盖

* feat(sessions): 新增分类视图收起状态控制

* test(sessions): 补充分类收起状态覆盖
2026-07-01 12:23:46 +08:00

251 lines
7.6 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";
export const UNKNOWN_PROJECT_DIR_KEY = "__unknown_project_dir__";
export interface SessionDirectoryGroup {
key: string;
projectDir: string | null;
label: string;
sessions: SessionMeta[];
}
export interface SessionProviderGroup {
providerId: string;
sessions: SessionMeta[];
directories: SessionDirectoryGroup[];
}
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 getSessionDirectoryGroupKey = (
providerId: string,
projectDir?: string | null,
) => {
const trimmed = projectDir?.trim();
return `${providerId}:${trimmed || UNKNOWN_PROJECT_DIR_KEY}`;
};
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 groupSessionsByProviderAndDirectory = (
sessions: SessionMeta[],
unknownDirectoryLabel: string,
): SessionProviderGroup[] => {
const providerGroups: SessionProviderGroup[] = [];
const providerGroupMap = new Map<string, SessionProviderGroup>();
const directoryGroupMaps = new Map<
string,
Map<string, SessionDirectoryGroup>
>();
sessions.forEach((session) => {
let providerGroup = providerGroupMap.get(session.providerId);
if (!providerGroup) {
providerGroup = {
providerId: session.providerId,
sessions: [],
directories: [],
};
providerGroupMap.set(session.providerId, providerGroup);
providerGroups.push(providerGroup);
directoryGroupMaps.set(session.providerId, new Map());
}
providerGroup.sessions.push(session);
const trimmedProjectDir = session.projectDir?.trim() || null;
const directoryKey = getSessionDirectoryGroupKey(
session.providerId,
trimmedProjectDir,
);
const directoryGroups = directoryGroupMaps.get(session.providerId)!;
let directoryGroup = directoryGroups.get(directoryKey);
if (!directoryGroup) {
directoryGroup = {
key: directoryKey,
projectDir: trimmedProjectDir,
label: trimmedProjectDir
? getBaseName(trimmedProjectDir) || trimmedProjectDir
: unknownDirectoryLabel,
sessions: [],
};
directoryGroups.set(directoryKey, directoryGroup);
providerGroup.directories.push(directoryGroup);
}
directoryGroup.sessions.push(session);
});
return providerGroups;
};
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,
);
};