mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 01:25:33 +08:00
feat: session manger (#867)
* feat: init session manger * feat: persist selected app to localStorage - Save app selection when switching providers - Restore last selected app on page load * feat: persist current view to localStorage - Save view selection when switching tabs - Restore last selected view on page load * styles: update ui * feat: Improve macOS Terminal activation and refactor Kitty launch to use command with user's default shell. * fix: session view * feat: toc * feat: Implement FlexSearch for improved session search functionality. * feat: Redesign session manager search and filter UI for a more compact and dynamic experience. * refactor: modularize session manager by extracting components and utility functions into dedicated files. * feat: Enhance session terminal launching with support for iTerm2, Ghostty, WezTerm, and Alacritty, including UI and custom configuration options. * feat: Conditionally render terminal selection and resume session buttons only on macOS.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { ChevronRight, Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import type { SessionMeta } from "@/types";
|
||||
import {
|
||||
formatRelativeTime,
|
||||
formatSessionTitle,
|
||||
getProviderIconName,
|
||||
getProviderLabel,
|
||||
getSessionKey,
|
||||
} from "./utils";
|
||||
|
||||
interface SessionItemProps {
|
||||
session: SessionMeta;
|
||||
isSelected: boolean;
|
||||
onSelect: (key: string) => void;
|
||||
}
|
||||
|
||||
export function SessionItem({
|
||||
session,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: SessionItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const title = formatSessionTitle(session);
|
||||
const lastActive = session.lastActiveAt || session.createdAt || undefined;
|
||||
const sessionKey = getSessionKey(session);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(sessionKey)}
|
||||
className={cn(
|
||||
"w-full text-left rounded-lg px-3 py-2.5 transition-all group",
|
||||
isSelected
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
: "hover:bg-muted/60 border border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0">
|
||||
<ProviderIcon
|
||||
icon={getProviderIconName(session.providerId)}
|
||||
name={session.providerId}
|
||||
size={18}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{getProviderLabel(session.providerId, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-sm font-medium truncate flex-1">{title}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
|
||||
isSelected && "text-primary rotate-90"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Clock className="size-3" />
|
||||
<span>
|
||||
{lastActive ? formatRelativeTime(lastActive) : t("common.unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSessionSearch } from "@/hooks/useSessionSearch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Copy,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Play,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
FolderOpen,
|
||||
Terminal,
|
||||
X,
|
||||
SquareTerminal,
|
||||
} from "lucide-react";
|
||||
import { useSessionMessagesQuery, useSessionsQuery } from "@/lib/query";
|
||||
import { sessionsApi } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { isMac } from "@/lib/platform";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import {
|
||||
AlacrittyIcon,
|
||||
GhosttyIcon,
|
||||
ITermIcon,
|
||||
KittyIcon,
|
||||
WezTermIcon,
|
||||
} from "@/components/icons/TerminalIcons";
|
||||
import { SessionItem } from "./SessionItem";
|
||||
import { SessionMessageItem } from "./SessionMessageItem";
|
||||
import { SessionTocDialog, SessionTocSidebar } from "./SessionToc";
|
||||
import {
|
||||
formatSessionTitle,
|
||||
formatTimestamp,
|
||||
getBaseName,
|
||||
getProviderIconName,
|
||||
getProviderLabel,
|
||||
getSessionKey,
|
||||
} from "./utils";
|
||||
|
||||
const TERMINAL_TARGET_KEY = "session_manager_terminal_target";
|
||||
|
||||
type TerminalTarget =
|
||||
| "terminal"
|
||||
| "iterm"
|
||||
| "ghostty"
|
||||
| "kitty"
|
||||
| "wezterm"
|
||||
| "alacritty";
|
||||
|
||||
type ProviderFilter = "all" | "codex" | "claude";
|
||||
|
||||
export function SessionManagerPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, refetch } = useSessionsQuery();
|
||||
const sessions = data ?? [];
|
||||
const detailRef = useRef<HTMLDivElement | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const messageRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const [activeMessageIndex, setActiveMessageIndex] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [tocDialogOpen, setTocDialogOpen] = useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [providerFilter, setProviderFilter] = useState<ProviderFilter>("all");
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [terminalTarget, setTerminalTarget] =
|
||||
useState<TerminalTarget>("terminal");
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const storedTarget = window.localStorage.getItem(
|
||||
TERMINAL_TARGET_KEY
|
||||
) as TerminalTarget | null;
|
||||
if (storedTarget) {
|
||||
setTerminalTarget(storedTarget);
|
||||
}
|
||||
|
||||
setIsLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
window.localStorage.setItem(TERMINAL_TARGET_KEY, terminalTarget);
|
||||
}
|
||||
}, [terminalTarget, isLoaded]);
|
||||
|
||||
// 使用 FlexSearch 全文搜索
|
||||
const { search: searchSessions } = useSessionSearch({
|
||||
sessions,
|
||||
providerFilter,
|
||||
});
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
return searchSessions(search);
|
||||
}, [searchSessions, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredSessions.length === 0) {
|
||||
setSelectedKey(null);
|
||||
return;
|
||||
}
|
||||
const exists = selectedKey
|
||||
? filteredSessions.some(
|
||||
(session) => getSessionKey(session) === selectedKey
|
||||
)
|
||||
: false;
|
||||
if (!exists) {
|
||||
setSelectedKey(getSessionKey(filteredSessions[0]));
|
||||
}
|
||||
}, [filteredSessions, selectedKey]);
|
||||
|
||||
const selectedSession = useMemo(() => {
|
||||
if (!selectedKey) return null;
|
||||
return (
|
||||
filteredSessions.find(
|
||||
(session) => getSessionKey(session) === selectedKey
|
||||
) || null
|
||||
);
|
||||
}, [filteredSessions, selectedKey]);
|
||||
|
||||
const { data: messages = [], isLoading: isLoadingMessages } =
|
||||
useSessionMessagesQuery(
|
||||
selectedSession?.providerId,
|
||||
selectedSession?.sourcePath
|
||||
);
|
||||
|
||||
// 提取用户消息用于目录
|
||||
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]);
|
||||
|
||||
const scrollToMessage = (index: number) => {
|
||||
const el = messageRefs.current.get(index);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
setActiveMessageIndex(index);
|
||||
setTocDialogOpen(false); // 关闭弹窗
|
||||
// 清除高亮状态
|
||||
setTimeout(() => setActiveMessageIndex(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 这里的 setTimeout 其实无法直接清理,因为它在函数闭包里。
|
||||
// 如果要严格清理,需要用 useRef 存 timer id。
|
||||
// 但对于 2秒的高亮清除,通常不清理也没大问题。
|
||||
// 为了代码规范,我们在组件卸载时将 activeMessageIndex 重置 (虽然 React 会处理)
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async (text: string, successMessage: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(successMessage);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
extractErrorMessage(error) ||
|
||||
t("common.error", { defaultValue: "Copy failed" })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async () => {
|
||||
if (!selectedSession?.resumeCommand) return;
|
||||
|
||||
if (!isMac()) {
|
||||
await handleCopy(
|
||||
selectedSession.resumeCommand,
|
||||
t("sessionManager.resumeCommandCopied")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sessionsApi.launchTerminal({
|
||||
target: terminalTarget,
|
||||
command: selectedSession.resumeCommand,
|
||||
cwd: selectedSession.projectDir ?? undefined,
|
||||
});
|
||||
toast.success(t("sessionManager.terminalLaunched"));
|
||||
} catch (error) {
|
||||
const fallback = selectedSession.resumeCommand;
|
||||
await handleCopy(fallback, t("sessionManager.resumeFallbackCopied"));
|
||||
toast.error(extractErrorMessage(error) || t("sessionManager.openFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="mx-auto px-4 sm:px-6 flex flex-col h-[calc(100vh-8rem)]">
|
||||
<div className="flex-1 overflow-hidden flex flex-col gap-4">
|
||||
{/* 主内容区域 - 左右分栏 */}
|
||||
<div className="flex-1 overflow-hidden grid gap-4 md:grid-cols-[320px_1fr]">
|
||||
{/* 左侧会话列表 */}
|
||||
<Card className="flex flex-col overflow-hidden">
|
||||
<CardHeader className="py-2 px-3 border-b">
|
||||
{isSearchOpen ? (
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("sessionManager.searchPlaceholder")}
|
||||
className="h-8 pl-8 pr-8 text-sm"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsSearchOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (search.trim() === "") {
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("sessionManager.sessionList")}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredSessions.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
setTimeout(
|
||||
() => searchInputRef.current?.focus(),
|
||||
0
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>搜索会话</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Select
|
||||
value={providerFilter}
|
||||
onValueChange={(value) =>
|
||||
setProviderFilter(value as ProviderFilter)
|
||||
}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
|
||||
<ProviderIcon
|
||||
icon={
|
||||
providerFilter === "all"
|
||||
? "apps"
|
||||
: providerFilter === "codex"
|
||||
? "openai"
|
||||
: "claude"
|
||||
}
|
||||
name={providerFilter}
|
||||
size={14}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{providerFilter === "all"
|
||||
? t("sessionManager.providerFilterAll")
|
||||
: providerFilter}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon icon="apps" name="all" size={14} />
|
||||
<span>
|
||||
{t("sessionManager.providerFilterAll")}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openai"
|
||||
name="codex"
|
||||
size={14}
|
||||
/>
|
||||
<span>Codex</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="claude">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="claude"
|
||||
name="claude"
|
||||
size={14}
|
||||
/>
|
||||
<span>Claude Code</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MessageSquare className="size-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sessionManager.noSessions")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredSessions.map((session) => {
|
||||
const isSelected =
|
||||
selectedKey !== null &&
|
||||
getSessionKey(session) === selectedKey;
|
||||
|
||||
return (
|
||||
<SessionItem
|
||||
key={getSessionKey(session)}
|
||||
session={session}
|
||||
isSelected={isSelected}
|
||||
onSelect={setSelectedKey}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 右侧会话详情 */}
|
||||
<Card
|
||||
className="flex flex-col overflow-hidden min-h-0"
|
||||
ref={detailRef}
|
||||
>
|
||||
{!selectedSession ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-8">
|
||||
<MessageSquare className="size-12 mb-3 opacity-30" />
|
||||
<p className="text-sm">{t("sessionManager.selectSession")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 详情头部 */}
|
||||
<CardHeader className="py-3 px-4 border-b shrink-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* 左侧:会话信息 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0">
|
||||
<ProviderIcon
|
||||
icon={getProviderIconName(
|
||||
selectedSession.providerId
|
||||
)}
|
||||
name={selectedSession.providerId}
|
||||
size={20}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{getProviderLabel(selectedSession.providerId, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<h2 className="text-base font-semibold truncate">
|
||||
{formatSessionTitle(selectedSession)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
<span>
|
||||
{formatTimestamp(
|
||||
selectedSession.lastActiveAt ??
|
||||
selectedSession.createdAt
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{selectedSession.projectDir && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleCopy(
|
||||
selectedSession.projectDir!,
|
||||
t("sessionManager.projectDirCopied")
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
>
|
||||
<FolderOpen className="size-3" />
|
||||
<span className="truncate max-w-[200px]">
|
||||
{getBaseName(selectedSession.projectDir)}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
className="max-w-xs"
|
||||
>
|
||||
<p className="font-mono text-xs break-all">
|
||||
{selectedSession.projectDir}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
点击复制路径
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮组 */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isMac() && (
|
||||
<>
|
||||
<Select
|
||||
value={terminalTarget}
|
||||
onValueChange={(value) =>
|
||||
setTerminalTarget(value as TerminalTarget)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 min-w-[110px] w-auto text-xs px-2.5">
|
||||
<Terminal className="size-3 mr-1.5" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="terminal">
|
||||
<div className="flex items-center gap-2">
|
||||
<SquareTerminal className="size-3.5" />
|
||||
<span>Terminal</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="iterm">
|
||||
<div className="flex items-center gap-2">
|
||||
<ITermIcon className="size-3.5" />
|
||||
<span>iTerm2 (Untested)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="ghostty">
|
||||
<div className="flex items-center gap-2">
|
||||
<GhosttyIcon className="size-3.5" />
|
||||
<span>Ghostty</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="kitty">
|
||||
<div className="flex items-center gap-2">
|
||||
<KittyIcon className="size-3.5" />
|
||||
<span>Kitty</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="wezterm">
|
||||
<div className="flex items-center gap-2">
|
||||
<WezTermIcon className="size-3.5" />
|
||||
<span>WezTerm (Untested)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="alacritty">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlacrittyIcon className="size-3.5" />
|
||||
<span>Alacritty (Untested)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => void handleResume()}
|
||||
disabled={!selectedSession.resumeCommand}
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
{t("sessionManager.resume", {
|
||||
defaultValue: "恢复会话",
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{selectedSession.resumeCommand
|
||||
? t("sessionManager.resumeTooltip", {
|
||||
defaultValue: "在终端中恢复此会话",
|
||||
})
|
||||
: t("sessionManager.noResumeCommand", {
|
||||
defaultValue: "此会话无法恢复",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 恢复命令预览 */}
|
||||
{selectedSession.resumeCommand && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="flex-1 rounded-md bg-muted/60 px-3 py-1.5 font-mono text-xs text-muted-foreground truncate">
|
||||
{selectedSession.resumeCommand}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={() =>
|
||||
void handleCopy(
|
||||
selectedSession.resumeCommand!,
|
||||
t("sessionManager.resumeCommandCopied")
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("sessionManager.copyCommand", {
|
||||
defaultValue: "复制命令",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{/* 消息列表区域 */}
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<div className="flex h-full">
|
||||
{/* 消息列表 */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MessageSquare className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("sessionManager.conversationHistory", {
|
||||
defaultValue: "对话记录",
|
||||
})}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{messages.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{isLoadingMessages ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MessageSquare className="size-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sessionManager.emptySession")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{messages.map((message, index) => (
|
||||
<SessionMessageItem
|
||||
key={`${message.role}-${index}`}
|
||||
message={message}
|
||||
index={index}
|
||||
isActive={activeMessageIndex === index}
|
||||
setRef={(el) => {
|
||||
if (el) messageRefs.current.set(index, el);
|
||||
}}
|
||||
onCopy={(content) =>
|
||||
handleCopy(
|
||||
content,
|
||||
t("sessionManager.messageCopied", {
|
||||
defaultValue: "已复制消息内容",
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 右侧目录 - 类似少数派 (大屏幕) */}
|
||||
<SessionTocSidebar
|
||||
items={userMessagesToc}
|
||||
onItemClick={scrollToMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 浮动目录按钮 (小屏幕) */}
|
||||
<SessionTocDialog
|
||||
items={userMessagesToc}
|
||||
onItemClick={scrollToMessage}
|
||||
open={tocDialogOpen}
|
||||
onOpenChange={setTocDialogOpen}
|
||||
/>
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SessionMessage } from "@/types";
|
||||
import { formatTimestamp, getRoleLabel, getRoleTone } from "./utils";
|
||||
|
||||
interface SessionMessageItemProps {
|
||||
message: SessionMessage;
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
setRef: (el: HTMLDivElement | null) => void;
|
||||
onCopy: (content: string) => void;
|
||||
}
|
||||
|
||||
export function SessionMessageItem({
|
||||
message,
|
||||
isActive,
|
||||
setRef,
|
||||
onCopy,
|
||||
}: SessionMessageItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRef}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2.5 relative group transition-all",
|
||||
message.role.toLowerCase() === "user"
|
||||
? "bg-primary/5 border-primary/20 ml-8"
|
||||
: message.role.toLowerCase() === "assistant"
|
||||
? "bg-blue-500/5 border-blue-500/20 mr-8"
|
||||
: "bg-muted/40 border-border/60",
|
||||
isActive && "ring-2 ring-primary ring-offset-2"
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 size-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => onCopy(message.content)}
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("sessionManager.copyMessage", {
|
||||
defaultValue: "复制内容",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex items-center justify-between text-xs mb-1.5 pr-6">
|
||||
<span className={cn("font-semibold", getRoleTone(message.role))}>
|
||||
{getRoleLabel(message.role)}
|
||||
</span>
|
||||
{message.ts && (
|
||||
<span className="text-muted-foreground">
|
||||
{formatTimestamp(message.ts)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { List, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface TocItem {
|
||||
index: number;
|
||||
preview: string;
|
||||
ts?: number;
|
||||
}
|
||||
|
||||
interface SessionTocSidebarProps {
|
||||
items: TocItem[];
|
||||
onItemClick: (index: number) => void;
|
||||
}
|
||||
|
||||
export function SessionTocSidebar({
|
||||
items,
|
||||
onItemClick,
|
||||
}: SessionTocSidebarProps) {
|
||||
if (items.length <= 2) return null;
|
||||
|
||||
return (
|
||||
<div className="w-64 border-l shrink-0 hidden xl:block">
|
||||
<div className="p-3 border-b">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<List className="size-3.5" />
|
||||
<span>对话目录</span>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[calc(100%-40px)]">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{items.map((item, tocIndex) => (
|
||||
<button
|
||||
key={item.index}
|
||||
type="button"
|
||||
onClick={() => onItemClick(item.index)}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded text-xs transition-colors",
|
||||
"hover:bg-muted/80 text-muted-foreground hover:text-foreground",
|
||||
"flex items-start gap-2"
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] flex items-center justify-center font-medium">
|
||||
{tocIndex + 1}
|
||||
</span>
|
||||
<span className="line-clamp-2 leading-snug">{item.preview}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionTocDialogProps {
|
||||
items: TocItem[];
|
||||
onItemClick: (index: number) => void;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SessionTocDialog({
|
||||
items,
|
||||
onItemClick,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: SessionTocDialogProps) {
|
||||
if (items.length <= 2) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className="fixed bottom-20 right-4 xl:hidden size-10 rounded-full shadow-lg z-30"
|
||||
>
|
||||
<List className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-w-md max-h-[70vh] flex flex-col p-0 gap-0"
|
||||
zIndex="alert"
|
||||
onInteractOutside={() => onOpenChange(false)}
|
||||
onEscapeKeyDown={() => onOpenChange(false)}
|
||||
>
|
||||
<DialogHeader className="px-4 py-3 relative border-b">
|
||||
<DialogTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<List className="size-4 text-primary" />
|
||||
对话目录
|
||||
</DialogTitle>
|
||||
<DialogClose
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1.5 hover:bg-muted transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="size-4 text-muted-foreground" />
|
||||
</DialogClose>
|
||||
</DialogHeader>
|
||||
<div className="overflow-y-auto max-h-[calc(70vh-80px)]">
|
||||
<div className="p-3 pb-4 space-y-1">
|
||||
{items.map((item, tocIndex) => (
|
||||
<button
|
||||
key={item.index}
|
||||
type="button"
|
||||
onClick={() => onItemClick(item.index)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all",
|
||||
"hover:bg-primary/10 text-foreground",
|
||||
"flex items-start gap-3",
|
||||
"focus:outline-none focus:ring-2 focus:ring-primary focus:ring-inset"
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs flex items-center justify-center font-semibold">
|
||||
{tocIndex + 1}
|
||||
</span>
|
||||
<span className="line-clamp-2 leading-relaxed pt-0.5">
|
||||
{item.preview}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { SessionMeta } from "@/types";
|
||||
|
||||
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) => {
|
||||
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 "刚刚";
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
if (days < 7) return `${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";
|
||||
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) => {
|
||||
const normalized = role.toLowerCase();
|
||||
if (normalized === "assistant") return "AI";
|
||||
if (normalized === "user") return "用户";
|
||||
if (normalized === "system") return "系统";
|
||||
if (normalized === "tool") return "工具";
|
||||
return role;
|
||||
};
|
||||
|
||||
export const formatSessionTitle = (session: SessionMeta) => {
|
||||
return (
|
||||
session.title ||
|
||||
getBaseName(session.projectDir) ||
|
||||
session.sessionId.slice(0, 8)
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user