mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "プロバイダー切り替えリスナーの設定に失敗:",
|
||||
|
||||
@@ -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": "设置供应商切换监听器失败:",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user