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, ) => { 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(); const directoryGroupMaps = new Map< string, Map >(); 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("") || (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, ); };