Files
CC-Switch/src/components/sessions/utils.ts
T
Thefool 1c0ee0c58a feat(grokbuild): add first-class Grok Build support (#5453)
* feat(grokbuild): add backend integration

* feat(grokbuild): add provider and management UI

* test(grokbuild): cover configuration and integrations

* fix(grokbuild): address backend review feedback

* fix(grokbuild): complete UI review feedback

* feat(grokbuild): add CLI lifecycle management

* fix(grokbuild): align provider icon fallback

* test(grokbuild): cover provider icon persistence
2026-07-17 15:50:50 +08:00

252 lines
7.7 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 === "grokbuild") return "grok";
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,
);
};