mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +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:
+78
-28
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user