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
+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]);
}