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:
TinsFox
2026-02-02 11:12:30 +08:00
committed by GitHub
parent 58153333ce
commit f0e8ba1d8f
32 changed files with 2926 additions and 32 deletions
+78 -28
View File
@@ -12,10 +12,11 @@ import {
Book,
Wrench,
RefreshCw,
Search,
Download,
History,
BarChart2,
Download,
FolderArchive,
Search,
} from "lucide-react";
import type { Provider, VisibleApps } from "@/types";
import type { EnvConflict } from "@/types/env";
@@ -54,6 +55,7 @@ import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { UniversalProviderPanel } from "@/components/universal";
import { McpIcon } from "@/components/BrandIcons";
import { Button } from "@/components/ui/button";
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
type View =
| "providers"
@@ -63,22 +65,59 @@ type View =
| "skillsDiscovery"
| "mcp"
| "agents"
| "universal";
| "universal"
| "sessions";
// macOS Overlay mode needs space for traffic light buttons, Windows/Linux use native titlebar
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
const HEADER_HEIGHT = 64; // px
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
const STORAGE_KEY = "cc-switch-last-app";
const VALID_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
const getInitialApp = (): AppId => {
const saved = localStorage.getItem(STORAGE_KEY) as AppId | null;
if (saved && VALID_APPS.includes(saved)) {
return saved;
}
return "claude";
};
const VIEW_STORAGE_KEY = "cc-switch-last-view";
const VALID_VIEWS: View[] = [
"providers",
"settings",
"prompts",
"skills",
"skillsDiscovery",
"mcp",
"agents",
"universal",
"sessions",
];
const getInitialView = (): View => {
const saved = localStorage.getItem(VIEW_STORAGE_KEY) as View | null;
if (saved && VALID_VIEWS.includes(saved)) {
return saved;
}
return "providers";
};
function App() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeApp, setActiveApp] = useState<AppId>("claude");
const [currentView, setCurrentView] = useState<View>("providers");
const [activeApp, setActiveApp] = useState<AppId>(getInitialApp);
const [currentView, setCurrentView] = useState<View>(getInitialView);
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
const [isAddOpen, setIsAddOpen] = useState(false);
useEffect(() => {
localStorage.setItem(VIEW_STORAGE_KEY, currentView);
}, [currentView]);
// Get settings for visibleApps
const { data: settingsData } = useSettingsQuery();
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
@@ -136,7 +175,7 @@ function App() {
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
const activeProviderId = useMemo(() => {
const target = proxyStatus?.active_targets?.find(
(t) => t.app_type === activeApp,
(t) => t.app_type === activeApp
);
return target?.provider_id;
}, [proxyStatus?.active_targets, activeApp]);
@@ -169,7 +208,7 @@ function App() {
if (event.appType === activeApp) {
await refetch();
}
},
}
);
} catch (error) {
console.error("[App] Failed to subscribe provider switch event", error);
@@ -203,7 +242,7 @@ function App() {
} catch (error) {
console.error(
"[App] Failed to subscribe universal-provider-synced event",
error,
error
);
}
};
@@ -231,7 +270,7 @@ function App() {
} catch (error) {
console.error(
"[App] Failed to check environment conflicts on startup:",
error,
error
);
}
};
@@ -247,7 +286,7 @@ function App() {
if (migrated) {
toast.success(
t("migration.success", { defaultValue: "配置迁移成功" }),
{ closeButton: true },
{ closeButton: true }
);
}
} catch (error) {
@@ -263,7 +302,7 @@ function App() {
const checkSkillsMigration = async () => {
try {
const result = await invoke<{ count: number; error?: string } | null>(
"get_skills_migration_result",
"get_skills_migration_result"
);
if (result?.error) {
toast.error(t("migration.skillsFailed"), {
@@ -297,10 +336,10 @@ function App() {
// 合并新检测到的冲突
setEnvConflicts((prev) => {
const existingKeys = new Set(
prev.map((c) => `${c.varName}:${c.sourcePath}`),
prev.map((c) => `${c.varName}:${c.sourcePath}`)
);
const newConflicts = conflicts.filter(
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`),
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`)
);
return [...prev, ...newConflicts];
});
@@ -312,7 +351,7 @@ function App() {
} catch (error) {
console.error(
"[App] Failed to check environment conflicts on app switch:",
error,
error
);
}
};
@@ -394,7 +433,7 @@ function App() {
t("notifications.removeFromConfigSuccess", {
defaultValue: "已从配置移除",
}),
{ closeButton: true },
{ closeButton: true }
);
} else {
// Delete from database
@@ -406,7 +445,7 @@ function App() {
// Generate a unique provider key for OpenCode duplication
const generateUniqueOpencodeKey = (
originalKey: string,
existingKeys: string[],
existingKeys: string[]
): string => {
const baseKey = `${originalKey}-copy`;
@@ -448,7 +487,7 @@ function App() {
const existingKeys = Object.keys(providers);
duplicatedProvider.providerKey = generateUniqueOpencodeKey(
provider.id,
existingKeys,
existingKeys
);
}
@@ -459,7 +498,7 @@ function App() {
(p) =>
p.sortIndex !== undefined &&
p.sortIndex >= newSortIndex! &&
p.id !== provider.id,
p.id !== provider.id
)
.map((p) => ({
id: p.id,
@@ -475,7 +514,7 @@ function App() {
toast.error(
t("provider.sortUpdateFailed", {
defaultValue: "排序更新失败",
}),
})
);
return; // 如果排序更新失败,不继续添加
}
@@ -493,7 +532,7 @@ function App() {
toast.success(
t("provider.terminalOpened", {
defaultValue: "终端已打开",
}),
})
);
} catch (error) {
console.error("[App] Failed to open terminal", error);
@@ -501,7 +540,7 @@ function App() {
toast.error(
t("provider.terminalOpenFailed", {
defaultValue: "打开终端失败",
}) + (errorMessage ? `: ${errorMessage}` : ""),
}) + (errorMessage ? `: ${errorMessage}` : "")
);
}
};
@@ -581,6 +620,9 @@ function App() {
<UniversalProviderPanel />
</div>
);
case "sessions":
return <SessionManagerPage />;
default:
return (
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
@@ -679,7 +721,7 @@ function App() {
} catch (error) {
console.error(
"[App] Failed to re-check conflicts after deletion:",
error,
error
);
}
}}
@@ -713,9 +755,7 @@ function App() {
size="icon"
onClick={() =>
setCurrentView(
currentView === "skillsDiscovery"
? "skills"
: "providers",
currentView === "skillsDiscovery" ? "skills" : "providers"
)
}
className="mr-2 rounded-lg"
@@ -734,6 +774,7 @@ function App() {
t("universalProvider.title", {
defaultValue: "统一供应商",
})}
{currentView === "sessions" && t("sessionManager.title")}
</h1>
</div>
) : (
@@ -747,7 +788,7 @@ function App() {
"text-xl font-semibold transition-colors",
isProxyRunning && isCurrentAppTakeoverActive
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
)}
>
CC Switch
@@ -893,7 +934,7 @@ function App() {
"transition-all duration-300 ease-in-out overflow-hidden",
isCurrentAppTakeoverActive
? "opacity-100 max-w-[100px] scale-100"
: "opacity-0 max-w-0 scale-75 pointer-events-none",
: "opacity-0 max-w-0 scale-75 pointer-events-none"
)}
>
<FailoverToggle activeApp={activeApp} />
@@ -921,7 +962,7 @@ function App() {
"transition-all duration-200 ease-in-out overflow-hidden",
hasSkillsSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1"
)}
title={t("skills.manage")}
>
@@ -948,6 +989,15 @@ function App() {
>
<Book className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("sessionManager.title")}
>
<History className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
+2
View File
@@ -10,6 +10,7 @@ interface AppSwitcherProps {
}
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
const STORAGE_KEY = "cc-switch-last-app";
export function AppSwitcher({
activeApp,
@@ -19,6 +20,7 @@ export function AppSwitcher({
}: AppSwitcherProps) {
const handleSwitch = (app: AppId) => {
if (app === activeApp) return;
localStorage.setItem(STORAGE_KEY, app);
onSwitch(app);
};
const iconSize = 20;
+190
View File
@@ -0,0 +1,190 @@
import { SVGProps } from "react";
export function ITermIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>iTerm2</title>
<path d="M24 5.359v13.282A5.36 5.36 0 0 1 18.641 24H5.359A5.36 5.36 0 0 1 0 18.641V5.359A5.36 5.36 0 0 1 5.359 0h13.282A5.36 5.36 0 0 1 24 5.359m-.932-.233A4.196 4.196 0 0 0 18.874.932H5.126A4.196 4.196 0 0 0 .932 5.126v13.748a4.196 4.196 0 0 0 4.194 4.194h13.748a4.196 4.196 0 0 0 4.194-4.194zm-.816.233v13.282a3.613 3.613 0 0 1-3.611 3.611H5.359a3.613 3.613 0 0 1-3.611-3.611V5.359a3.613 3.613 0 0 1 3.611-3.611h13.282a3.613 3.613 0 0 1 3.611 3.611M8.854 4.194v6.495h.962V4.194zM5.483 9.493v1.085h.597V9.48q.283-.037.508-.133.373-.165.575-.448.208-.284.208-.649a.9.9 0 0 0-.171-.568 1.4 1.4 0 0 0-.426-.388 3 3 0 0 0-.544-.261 32 32 0 0 0-.545-.209 1.8 1.8 0 0 1-.426-.216q-.164-.12-.164-.284 0-.223.179-.351.18-.126.485-.127.344 0 .575.105.239.105.5.298l.433-.5a2.3 2.3 0 0 0-.605-.433 1.6 1.6 0 0 0-.582-.159v-.968h-.597v.978a2 2 0 0 0-.477.127 1.2 1.2 0 0 0-.545.411q-.194.268-.194.634 0 .335.164.56.164.224.418.38a4 4 0 0 0 .552.262q.291.104.545.209.261.104.425.238a.39.39 0 0 1 .165.321q0 .225-.187.359-.18.134-.537.134-.381 0-.717-.134a4.4 4.4 0 0 1-.649-.351l-.388.589q.209.173.477.306.276.135.575.217.191.046.373.064" />
</svg>
);
}
export function AlacrittyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Alacritty</title>
<path d="m10.065 0-8.57 21.269h3.595l6.91-16.244 6.91 16.244h3.594l-8.57-21.269zm1.935 9.935c-0.76666 1.8547-1.5334 3.7094-2.298 5.565 1.475 4.54 1.475 4.54 2.298 8.5 0.823-3.96 0.823-3.96 2.297-8.5-0.76637-1.8547-1.5315-3.7099-2.297-5.565z" />
</svg>
);
}
export function WezTermIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>WezTerm</title>
<path d="M3.27 8.524c0-.623.62-1.007 2.123-1.007l-.5 2.757c-.931-.623-1.624-1.199-1.624-1.75zm4.008 6.807c0 .647-.644 1.079-2.123 1.15l.524-2.924c.931.624 1.6 1.175 1.6 1.774zm-2.625 5.992.454-2.708c3.603-.336 5.01-1.798 5.01-3.404 0-1.653-2.004-2.948-3.841-4.074l.668-3.548c.764.072 1.67.216 2.744.432l.31-2.469c-.81-.12-1.575-.168-2.29-.216L8.257 2.7l-2.363-.024-.453 2.684C1.838 5.648.43 7.158.43 8.764c0 1.63 2.004 2.876 3.841 3.954l-.668 3.716c-.859-.048-1.908-.192-3.125-.408L0 18.495c1.026.12 1.98.192 2.84.216l-.525 2.588zm15.553-1.894h2.673c.334-2.804.81-8.46 1.121-14.86h-2.553c-.071 1.51-.334 10.498-.43 11.241h-.071c-.644-2.42-1.169-4.386-1.813-6.782h-1.456c-.62 2.396-1.05 4.194-1.694 6.782h-.096c-.071-.743-.477-9.73-.525-11.24h-2.648c.31 6.399.763 12.055 1.097 14.86h2.625l1.838-7.12z" />
</svg>
);
}
export function GhosttyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Ghostty</title>
<path d="M12 0C6.7 0 2.4 4.3 2.4 9.6v11.146c0 1.772 1.45 3.267 3.222 3.254a3.18 3.18 0 0 0 1.955-.686 1.96 1.96 0 0 1 2.444 0 3.18 3.18 0 0 0 1.976.686c.75 0 1.436-.257 1.98-.686.715-.563 1.71-.587 2.419-.018.59.476 1.355.743 2.182.699 1.705-.094 3.022-1.537 3.022-3.244V9.601C21.6 4.3 17.302 0 12 0M6.069 6.562a1 1 0 0 1 .46.131l3.578 2.065v.002a.974.974 0 0 1 0 1.687L6.53 12.512a.975.975 0 0 1-.976-1.687L7.67 9.602 5.553 8.38a.975.975 0 0 1 .515-1.818m7.438 2.063h4.7a.975.975 0 1 1 0 1.95h-4.7a.975.975 0 0 1 0-1.95" />
</svg>
);
}
export function KittyIcon(props: SVGProps<SVGSVGElement>) {
// Official icon is complex and has fixed width/height/viewBox in original.
// Simplifying viewBox to 0 0 256 256 effectively as original was 240x240 but translated.
// Original viewBox="0 0 240 240" with g transform="translate(0 -812.362)" and elements around y=850.
// 850 - 812 = 38. So it's confusing.
// Let's copy the raw SVG content but adapt it to be a component.
// To make it behave like an icon, we should probably set viewBox="0 0 240 240" and keep the transform.
// It relies on fill colors. If we want it to be monochrome (currentColor), we should remove fills or set them to currentColor.
// However, official icons often have brand colors. The user said "official icon", which implies color.
// But usually in a dropdown we might want monochrome or original color.
// simple-icons are usually monochrome.
// Let's keep Kitty as original color since it's complex, OR mono if it works?
// The kitty icon has multiple paths with different colors. I'll preserve them for now as it's "official".
// If it looks weird in dark mode/light mode, we might need to adjust.
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 240 240"
{...props} // Allow overriding width/height
>
<g transform="translate(0 -812.362)">
<rect
width="100.446"
height="161.551"
x="72.824"
y="850.13"
ry="0"
style={{
fill: "#ddd",
fillOpacity: 1,
fillRule: "evenodd",
stroke: "none",
strokeWidth: 5.86876726,
strokeLinecap: "round",
strokeLinejoin: "round",
strokeMiterlimit: 4,
strokeDasharray: "none",
strokeOpacity: 1,
}}
/>
<path
d="M67.896 1029.71h104.208a7.065 7.065 0 0 0 7.065-7.066V918.436a7.065 7.065 0 0 0-7.065-7.065H67.896a7.065 7.065 0 0 0-7.065 7.065v104.208a7.065 7.065 0 0 0 7.065 7.065m55.813-38.35h37.444a4.239 4.239 0 0 1 0 8.479H123.71a4.239 4.239 0 0 1 0-8.478m-45.032-45.71a4.239 4.239 0 0 1 5.991-5.99l26.48 26.464a4.24 4.24 0 0 1 0 5.992l-26.48 26.48a4.239 4.239 0 0 1-5.991-5.992l23.484-23.484z"
style={{ strokeWidth: 1.41299629 }}
/>
<path
d="M96.085 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.385 3.574-3.385 8.17 0 4.595 1.504 8.17 3.385 8.17"
style={{
clipRule: "evenodd",
fill: "#c0c81f",
fillOpacity: 1,
fillRule: "evenodd",
strokeWidth: 3.09913683,
}}
/>
<path
d="M193.128 836.886c-4.596-4.85-25.53 1.022-38.295 8.936-9.957-5.106-21.956-8.17-34.721-8.17-13.02 0-25.02 3.064-34.977 8.17-12.765-7.914-33.955-14.042-38.295-8.936-4.595 5.106 3.32 26.296 12.765 38.04-.766 3.064-1.276 6.128-1.276 9.446 0 10.212 4.34 19.659 11.744 27.318h42.124c-1.276-2.553.511-4.085 8.17-4.085 7.659.255 9.19 1.532 8.17 4.085h42.124c7.404-7.66 11.744-17.36 11.744-27.318 0-3.318-.51-6.382-1.276-9.446 8.935-11.744 16.594-33.189 11.999-38.04m-97.015 67.4c-8.935 0-16.339-7.404-16.339-16.34s7.404-16.339 16.34-16.339 16.339 7.404 16.339 16.34-7.404 16.339-16.34 16.339m47.997 0c-8.936 0-16.34-7.404-16.34-16.34s7.404-16.339 16.34-16.339 16.34 7.404 16.34 16.34-7.15 16.339-16.34 16.339"
style={{
clipRule: "evenodd",
fill: "#784421",
fillOpacity: 1,
fillRule: "evenodd",
strokeWidth: 2.55301046,
}}
/>
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
<path
d="M168.507 903.265c15.318-19.148 46.72-28.339 67.655-15.063-24.509-3.83-46.72 2.553-67.655 15.063"
style={{
clipRule: "evenodd",
fillRule: "evenodd",
strokeWidth: 2.55301046,
fill: "#2b1100",
fillOpacity: 1,
}}
/>
<path
d="M167.486 898.67c8.68-20.425 34.466-33.7 55.145-26.552-21.7 2.808-39.316 11.233-55.145 26.551m-.256 9.957c15.83-15.063 50.806-20.169 61.528-4.34-21.7-6.893-40.593-3.83-61.527 4.34"
style={{
clipRule: "evenodd",
fillRule: "evenodd",
strokeWidth: 2.55301046,
fill: "#2b1100",
fillOpacity: 1,
}}
/>
</g>
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
<path
d="M71.493 903.265c-15.318-19.148-46.72-28.339-67.655-15.063 24.509-3.83 46.72 2.553 67.655 15.063"
style={{
clipRule: "evenodd",
fillRule: "evenodd",
strokeWidth: 2.55301046,
fill: "#2b1100",
fillOpacity: 1,
}}
/>
<path
d="M72.514 898.67c-8.68-20.425-34.466-33.7-55.145-26.552 21.7 2.808 39.316 11.233 55.145 26.551m.256 9.957c-15.83-15.063-50.806-20.169-61.528-4.34 21.7-6.893 40.593-3.83 61.527 4.34"
style={{
clipRule: "evenodd",
fillRule: "evenodd",
strokeWidth: 2.55301046,
fill: "#2b1100",
fillOpacity: 1,
}}
/>
</g>
<path
d="M52.6 893.563c-6.382 0-11.743 3.32-14.296 8.425h-.766c-6.893 0-12.765 5.106-12.765 11.489 0 8.935 9.19 13.786 17.615 10.722 5.106 7.404 16.084 7.915 20.17 0 6.126-.255 16.083-1.276 17.615-10.722 1.021-6.383-5.617-11.489-12.765-11.489h-.766c-2.042-5.106-7.659-8.425-14.041-8.425m134.8 0c6.382 0 11.743 3.32 14.296 8.425h.766c3.574 0 12.765 5.106 12.765 11.489 0 8.935-9.19 13.786-17.615 10.722-5.107 7.404-16.084 7.915-20.17 0-6.126-.255-16.083-1.276-17.615-10.722-1.021-6.383 9.19-11.489 12.765-11.489h.766c2.042-5.106 7.659-8.425 14.041-8.425"
style={{
clipRule: "evenodd",
fill: "#483737",
fillOpacity: 1,
fillRule: "evenodd",
strokeWidth: 2.55301046,
}}
/>
<path
d="M143.542 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.386 3.574-3.386 8.17 0 4.595 1.505 8.17 3.386 8.17"
style={{
clipRule: "evenodd",
fill: "#c0c81f",
fillOpacity: 1,
fillRule: "evenodd",
strokeWidth: 3.09913683,
}}
/>
</g>
</svg>
);
}
+78
View File
@@ -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>
);
}
+134
View File
@@ -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>
);
}
+75
View File
@@ -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)
);
};
+48
View File
@@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
+1 -1
View File
@@ -82,7 +82,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex w-full cursor-default select-none items-center rounded-sm pl-7 pr-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+134
View File
@@ -0,0 +1,134 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import FlexSearch from "flexsearch";
import type { SessionMeta } from "@/types";
// FlexSearch Index 类型
type FlexSearchIndex = InstanceType<typeof FlexSearch.Index>;
interface UseSessionSearchOptions {
sessions: SessionMeta[];
providerFilter: string;
}
interface UseSessionSearchResult {
search: (query: string) => SessionMeta[];
isIndexing: boolean;
}
/**
* 使用 FlexSearch 实现会话全文搜索
* 索引会话元数据(标题、摘要、项目目录等)
*/
export function useSessionSearch({
sessions,
providerFilter,
}: UseSessionSearchOptions): UseSessionSearchResult {
const [isIndexing, setIsIndexing] = useState(false);
// 会话元数据索引
const indexRef = useRef<FlexSearchIndex | null>(null);
// 索引 ID 到 session 的映射
const sessionByIdxRef = useRef<SessionMeta[]>([]);
// 初始化索引
useEffect(() => {
setIsIndexing(true);
// 创建索引实例
// 使用 forward tokenizer 支持中文前缀搜索
const index = new FlexSearch.Index({
tokenize: "forward",
resolution: 9,
});
// 索引所有会话
sessions.forEach((session, idx) => {
// 索引会话元数据
const metaContent = [
session.sessionId,
session.title,
session.summary,
session.projectDir,
session.sourcePath,
]
.filter(Boolean)
.join(" ");
index.add(idx, metaContent);
});
indexRef.current = index;
sessionByIdxRef.current = sessions;
setIsIndexing(false);
}, [sessions]);
// 搜索函数
const search = useCallback(
(query: string): SessionMeta[] => {
const needle = query.trim().toLowerCase();
// 先按 provider 过滤
let filtered = sessions;
if (providerFilter !== "all") {
filtered = sessions.filter((s) => s.providerId === providerFilter);
}
// 如果没有搜索词,返回按时间排序的结果
if (!needle) {
return [...filtered].sort((a, b) => {
const aTs = a.lastActiveAt ?? a.createdAt ?? 0;
const bTs = b.lastActiveAt ?? b.createdAt ?? 0;
return bTs - aTs;
});
}
const index = indexRef.current;
if (!index) {
// 索引未就绪,使用简单搜索
return filtered
.filter((session) => {
const haystack = [
session.sessionId,
session.title,
session.summary,
session.projectDir,
session.sourcePath,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(needle);
})
.sort((a, b) => {
const aTs = a.lastActiveAt ?? a.createdAt ?? 0;
const bTs = b.lastActiveAt ?? b.createdAt ?? 0;
return bTs - aTs;
});
}
// 使用 FlexSearch 搜索
const results = index.search(needle, { limit: 100 }) as number[];
// 转换为 session 并过滤
const matchedSessions = results
.map((idx) => sessionByIdxRef.current[idx])
.filter(
(session) =>
session &&
(providerFilter === "all" || session.providerId === providerFilter)
);
// 按时间排序
return matchedSessions.sort((a, b) => {
const aTs = a.lastActiveAt ?? a.createdAt ?? 0;
const bTs = b.lastActiveAt ?? b.createdAt ?? 0;
return bTs - aTs;
});
},
[sessions, providerFilter]
);
return useMemo(() => ({ search, isIndexing }), [search, isIndexing]);
}
+29
View File
@@ -400,6 +400,35 @@
"gemini": "Gemini",
"opencode": "OpenCode"
},
"sessionManager": {
"title": "Session Manager",
"subtitle": "Manage Codex and Claude Code sessions",
"searchPlaceholder": "Search by content, directory, or ID",
"providerFilterAll": "All",
"sessionList": "Sessions",
"loadingSessions": "Loading sessions...",
"noSessions": "No sessions found",
"selectSession": "Select a session to view details",
"noSummary": "No summary available",
"lastActive": "Last active",
"projectDir": "Project directory",
"sourcePath": "Source file",
"copyResumeCommand": "Copy resume command",
"resumeCommandCopied": "Resume command copied",
"openInTerminal": "Resume in terminal",
"terminalTargetTerminal": "Terminal",
"terminalTargetKitty": "kitty",
"terminalTargetCopy": "Copy only",
"terminalLaunched": "Terminal launched",
"openFailed": "Failed to launch terminal",
"resumeFallbackCopied": "Resume command copied for manual use",
"copyProjectDir": "Copy directory",
"projectDirCopied": "Directory copied",
"copySourcePath": "Copy source file",
"sourcePathCopied": "Source file copied",
"loadingMessages": "Loading transcript...",
"emptySession": "No messages available"
},
"console": {
"providerSwitchReceived": "Received provider switch event:",
"setupListenerFailed": "Failed to setup provider switch listener:",
+29
View File
@@ -400,6 +400,35 @@
"gemini": "Gemini",
"opencode": "OpenCode"
},
"sessionManager": {
"title": "セッション管理",
"subtitle": "Codex / Claude Code のセッションを管理",
"searchPlaceholder": "内容・ディレクトリ・ID で検索",
"providerFilterAll": "すべて",
"sessionList": "セッション一覧",
"loadingSessions": "セッションを読み込み中...",
"noSessions": "セッションが見つかりません",
"selectSession": "セッションを選択してください",
"noSummary": "概要なし",
"lastActive": "最終アクティブ",
"projectDir": "プロジェクトディレクトリ",
"sourcePath": "元ファイル",
"copyResumeCommand": "再開コマンドをコピー",
"resumeCommandCopied": "再開コマンドをコピーしました",
"openInTerminal": "ターミナルで再開",
"terminalTargetTerminal": "Terminal",
"terminalTargetKitty": "kitty",
"terminalTargetCopy": "コピーのみ",
"terminalLaunched": "ターミナルを起動しました",
"openFailed": "ターミナルの起動に失敗しました",
"resumeFallbackCopied": "再開コマンドをコピーしました(手動で実行してください)",
"copyProjectDir": "ディレクトリをコピー",
"projectDirCopied": "ディレクトリをコピーしました",
"copySourcePath": "元ファイルをコピー",
"sourcePathCopied": "元ファイルをコピーしました",
"loadingMessages": "内容を読み込み中...",
"emptySession": "表示できる内容がありません"
},
"console": {
"providerSwitchReceived": "プロバイダー切り替えイベントを受信:",
"setupListenerFailed": "プロバイダー切り替えリスナーの設定に失敗:",
+29
View File
@@ -400,6 +400,35 @@
"gemini": "Gemini",
"opencode": "OpenCode"
},
"sessionManager": {
"title": "会话管理",
"subtitle": "管理 Codex 与 Claude Code 会话记录",
"searchPlaceholder": "搜索会话内容、目录或 ID",
"providerFilterAll": "全部",
"sessionList": "会话列表",
"loadingSessions": "加载会话中...",
"noSessions": "未发现会话",
"selectSession": "请选择会话查看详情",
"noSummary": "暂无摘要",
"lastActive": "最近活跃",
"projectDir": "项目目录",
"sourcePath": "原始文件",
"copyResumeCommand": "复制恢复命令",
"resumeCommandCopied": "恢复命令已复制",
"openInTerminal": "在终端恢复",
"terminalTargetTerminal": "Terminal",
"terminalTargetKitty": "kitty",
"terminalTargetCopy": "仅复制",
"terminalLaunched": "终端已启动",
"openFailed": "终端启动失败",
"resumeFallbackCopied": "已复制恢复命令,可手动粘贴到终端",
"copyProjectDir": "复制目录",
"projectDirCopied": "目录已复制",
"copySourcePath": "复制原始文件",
"sourcePathCopied": "原始文件已复制",
"loadingMessages": "加载会话内容中...",
"emptySession": "该会话暂无可展示内容"
},
"console": {
"providerSwitchReceived": "收到供应商切换事件:",
"setupListenerFailed": "设置供应商切换监听器失败:",
+1
View File
@@ -7,6 +7,7 @@ export { skillsApi } from "./skills";
export { usageApi } from "./usage";
export { vscodeApi } from "./vscode";
export { proxyApi } from "./proxy";
export { sessionsApi } from "./sessions";
export * as configApi from "./config";
export type { ProviderSwitchEvent } from "./providers";
export type { Prompt } from "./prompts";
+30
View File
@@ -0,0 +1,30 @@
import { invoke } from "@tauri-apps/api/core";
import type { SessionMessage, SessionMeta } from "@/types";
export const sessionsApi = {
async list(): Promise<SessionMeta[]> {
return await invoke("list_sessions");
},
async getMessages(
providerId: string,
sourcePath: string
): Promise<SessionMessage[]> {
return await invoke("get_session_messages", { providerId, sourcePath });
},
async launchTerminal(options: {
target: string;
command: string;
cwd?: string | null;
customConfig?: string | null;
}): Promise<boolean> {
const { target, command, cwd, customConfig } = options;
return await invoke("launch_session_terminal", {
target,
command,
cwd,
customConfig,
});
},
};
+34 -2
View File
@@ -3,8 +3,20 @@ import {
type UseQueryResult,
keepPreviousData,
} from "@tanstack/react-query";
import { providersApi, settingsApi, usageApi, type AppId } from "@/lib/api";
import type { Provider, Settings, UsageResult } from "@/types";
import {
providersApi,
settingsApi,
usageApi,
sessionsApi,
type AppId,
} from "@/lib/api";
import type {
Provider,
Settings,
UsageResult,
SessionMeta,
SessionMessage,
} from "@/types";
const sortProviders = (
providers: Record<string, Provider>,
@@ -132,3 +144,23 @@ export const useUsageQuery = (
lastQueriedAt: query.dataUpdatedAt || null,
};
};
export const useSessionsQuery = () => {
return useQuery<SessionMeta[]>({
queryKey: ["sessions"],
queryFn: async () => sessionsApi.list(),
staleTime: 30 * 1000,
});
};
export const useSessionMessagesQuery = (
providerId?: string,
sourcePath?: string,
) => {
return useQuery<SessionMessage[]>({
queryKey: ["sessionMessages", providerId, sourcePath],
queryFn: async () => sessionsApi.getMessages(providerId!, sourcePath!),
enabled: Boolean(providerId && sourcePath),
staleTime: 30 * 1000,
});
};
+18
View File
@@ -213,6 +213,24 @@ export interface Settings {
preferredTerminal?: string;
}
export interface SessionMeta {
providerId: string;
sessionId: string;
title?: string;
summary?: string;
projectDir?: string | null;
createdAt?: number;
lastActiveAt?: number;
sourcePath?: string;
resumeCommand?: string;
}
export interface SessionMessage {
role: string;
content: string;
ts?: number;
}
// MCP 服务器连接参数(宽松:允许扩展字段)
export interface McpServerSpec {
// 可选:社区常见 .mcp.json 中 stdio 配置可不写 type