diff --git a/.gitignore b/.gitignore index 49a8be783..411f24951 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ release/ *.tsbuildinfo .npmrc CLAUDE.md -AGENTS.md +# AGENTS.md GEMINI.md /.claude /.codex diff --git a/package.json b/package.json index 466590d00..1bf485644 100644 --- a/package.json +++ b/package.json @@ -57,10 +57,12 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.90.3", "@tauri-apps/api": "^2.8.0", @@ -72,6 +74,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "codemirror": "^6.0.2", + "flexsearch": "^0.8.212", "framer-motion": "^12.23.25", "i18next": "^25.5.2", "jsonc-parser": "^3.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfe31cb50..65db84933 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.7 version: 2.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-scroll-area': + specifier: ^1.2.10 + version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-select': specifier: ^2.2.6 version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -71,6 +74,9 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.13 version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-visually-hidden': specifier: ^1.2.4 version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -104,6 +110,9 @@ importers: codemirror: specifier: ^6.0.2 version: 6.0.2 + flexsearch: + specifier: ^0.8.212 + version: 0.8.212 framer-motion: specifier: ^12.23.25 version: 12.23.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1117,6 +1126,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.2.6': resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} peerDependencies: @@ -1174,6 +1196,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -1323,56 +1358,67 @@ packages: resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.46.2': resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.46.2': resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.46.2': resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.46.2': resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.46.2': resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.46.2': resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.46.2': resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.46.2': resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.46.2': resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.46.2': resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.46.2': resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==} @@ -1429,30 +1475,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.8.1': resolution: {integrity: sha512-VK/zwBzQY9SfyK7RSrxlIRQLJyhyssoByYWPK/FJMre8SV/y8zZ071cTQNG9dPWM1f+onI1WPTleG+TBUq/0Gw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.8.1': resolution: {integrity: sha512-bFw3zK6xkyurDR5kw2QgiU6YFlFNrfgtli3wRdTRv8zSVLZMQ2iZ8keYnd57vpvsbZ9PusFPYAMS7Fkzkf9I4g==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.8.1': resolution: {integrity: sha512-zOnFX+Rppuz0UVVSeCi67lMet8le+yT4UIiQ6t/QYGtpoWO/D4GpMoVYehJlR14klNXrC2CRxT9b3BUWTCEBwA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.8.1': resolution: {integrity: sha512-gLy6eisaeOTC6NQirs3a0XZNCVT/i7JPYHkXx6ArH6+Kb9IU8ogthTY4MQoYbkWmdOp3ijKX+RT1dD3IZURrEg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.8.1': resolution: {integrity: sha512-ciZ93Dm847zFDqRyc1e0YRiu/cdWne1bMhvifcZOibbyqSKB9o+b95Y5axMtXqR4Wsd2mHiC5TE+MVF3NDsdEw==} @@ -1995,6 +2046,9 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + flexsearch@0.8.212: + resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==} + form-data@4.0.4: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} @@ -2211,24 +2265,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -3905,6 +3963,23 @@ snapshots: '@types/react': 18.3.23 '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.1 @@ -3979,6 +4054,26 @@ snapshots: '@types/react': 18.3.23 '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.23)(react@18.3.1)': dependencies: react: 18.3.1 @@ -4758,6 +4853,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + flexsearch@0.8.212: {} + form-data@4.0.4: dependencies: asynckit: 0.4.0 diff --git a/session-manager.md b/session-manager.md new file mode 100644 index 000000000..3c33cc376 --- /dev/null +++ b/session-manager.md @@ -0,0 +1,268 @@ +# 会话管理(Session Manager)需求文档(PRD / Markdown) + +> 目标:对 **Codex / Claude Code** 的本地会话记录进行可视化管理,并提供“一键复制 / 一键终端恢复”能力。 +> 范围:**v1 仅 macOS**,但必须预留多平台扩展入口。 + +--- + +## 1. 背景与问题 + +开发者同时使用 Codex CLI、Claude Code 时,常见痛点: +- 会话记录落在本地不同位置,**难以发现/检索** +- 找到会话后,恢复命令需要记忆或翻历史,**恢复成本高** +- 恢复时经常忘了当时的工作目录,导致命令在错误目录运行 +- 希望在常用终端(macOS Terminal、kitty 等)中直接恢复,提高效率 + +--- + +## 2. 目标与非目标 + +### 2.1 Goals(v1 必达) +1. 扫描并展示本机所有 Codex / Claude Code 会话:列表 + 详情(会话内容) +2. 支持恢复会话: + - 复制恢复命令(按钮) + - 复制会话目录(按钮,若能获取/推断) + - 可选:直接在终端执行恢复(macOS Terminal、kitty;可扩展) +3. 仅 macOS 支持,但代码结构需支持未来扩展 Windows/Linux + +### 2.2 Non-Goals(v1 不做) +- 不新增/依赖云端 API;默认不上传任何内容 +- 不承诺解析所有 provider 的全部内部格式(尽量兼容、可配置、可降级) +- 不做复杂的团队协作/分享/同步(后续版本再考虑) + +--- + +## 3. 用户画像与使用场景 + +### 3.1 典型用户 +- 高频使用多个 AI 编程工具的工程师/技术负责人/PM +- 多项目、多分支并行,频繁“中断—恢复—继续推进” + +### 3.2 核心场景(Top) +1. **找回会话**:我记得一个会话讨论过某段逻辑 → 搜索关键词 → 打开详情 +2. **快速恢复**:我想继续昨天的会话 → 复制恢复命令 / 一键在终端恢复 +3. **回到正确目录**:恢复前先复制目录或自动 cd 到目录 + +--- + +## 4. 产品形态与信息架构 + +### 4.1 信息架构 +- Session Manager + - 会话列表(List) + - 会话详情(Detail) + - 设置(Settings) + - Provider 配置(路径/启用禁用) + - 终端集成(默认终端、权限提示、降级策略) + - 索引与隐私选项(是否缓存、缓存大小、敏感信息遮罩) + +--- + +## 5. 功能需求(Functional Requirements) + +### 5.1 会话发现与索引(Discovery & Indexing) +**FR-1** 扫描本地会话数据源,生成统一的 Session 列表 +- 支持 Provider:Codex、Claude Code(可扩展) +- 支持全量扫描 + 增量更新 +- 支持缺失/异常文件的容错(不中断 UI) + +**FR-2** 本地索引(Cache/DB) +- 用于加速列表加载与搜索 +- 索引字段至少包含:sessionId、provider、lastActiveAt、projectDir(可空)、summary(可空)、filePath(可空) + +**FR-3** 数据源路径探测(可配置 + 多候选) +- 默认使用常见路径;允许用户在 Settings 覆盖 +- 若无法探测到 provider 安装/数据目录:在 UI 显示未启用/不可用状态,但不报错崩溃 + +--- + +### 5.2 会话列表(List) +**FR-4** 列表展示字段(建议最小集) +- Provider(Codex / Claude) +- Session 标识(id/short id) +- 最近活跃时间(lastActiveAt) +- 目录(projectDir,若未知显示 “Unknown”) +- 摘要(summary:最后一条/首条截断或规则生成) + +**FR-5** 列表交互 +- 搜索(跨会话,关键词匹配 transcript/summary/目录) +- 过滤:Provider、是否有目录、时间范围 +- 排序:最近活跃(默认)、最早、按目录 + +**FR-6** 空态/异常态 +- 未发现任何会话:给出“如何启用/设置路径”的指引 +- 发现会话但无法解析内容:列表仍可显示基本信息,并在详情页提示“解析失败” + +--- + +### 5.3 会话详情(Detail) +**FR-7** 会话内容展示 +- 时间线展示消息(role:user/assistant/tool 等) +- 支持在当前会话内搜索 + 高亮 +- 展示元信息: + - provider、sessionId、创建/最近活跃时间 + - projectDir(可空) + - 原始文件路径(可选显示,便于 debug) + +**FR-8** 性能策略 +- 默认按需加载(打开详情才加载全文) +- 对超长 transcript 支持分页/虚拟列表(防止卡顿) + +--- + +### 5.4 恢复能力(Resume / Restore) +#### 5.4.1 复制恢复命令(必做) +**FR-9** “复制恢复命令”按钮 +- 根据 provider 生成恢复命令(模板可配置) +- 点击后写入剪贴板,并 toast 提示成功 + +> 说明:不同版本 CLI 命令可能略有差异,建议将命令模板做成可配置项(Settings),默认提供推荐模板。 + +#### 5.4.2 复制会话目录(尽量做) +**FR-10** “复制会话目录”按钮 +- 当 projectDir 可得时启用;不可得时置灰,并提示原因(无法推断目录) +- 复制内容为可直接 `cd` 的绝对路径(或原样) + +#### 5.4.3 一键终端恢复(可选但强烈建议) +**FR-11** “在终端恢复”按钮(或下拉菜单) +- 默认目标:macOS Terminal +- 支持 kitty(v1 要求) +- 执行策略: + - `cd "" && `(若 projectDir 为空则仅执行 resumeCommand) +- 失败降级: + - 无权限/终端不可用 → 自动降级为“仅复制命令”,并提示用户如何修复(例如开启 Automation 权限、kitty remote control) + +**FR-12** 终端目标选择与记忆 +- 下拉选择:Terminal / kitty /(预留 iTerm2)/ 仅复制 +- 记住上次选择作为默认 + +--- + +## 6. 平台与扩展性设计(macOS v1 + Future-proof) + +### 6.1 Provider Adapter 抽象(必须) +统一接口(示例): +- `detect(): boolean` +- `scanSessions(): SessionMeta[]` +- `loadTranscript(sessionId): Message[]` +- `getResumeCommand(sessionId): string` +- `getProjectDir(sessionId): string | null` + +### 6.2 Terminal Launcher 抽象(必须) +- `launch(command: string, cwd?: string, targetTerminal: TerminalKind): Result` +- macOS v1 实现:TerminalLauncherMac +- Future:TerminalLauncherWindows / TerminalLauncherLinux + +### 6.3 Path Resolver(必须) +- `resolveProviderDataPaths(providerId): string[]` +- v1 返回 macOS 默认候选;允许 Settings 覆盖 + +--- + +## 7. 隐私与安全(Privacy & Security) + +**默认原则:全本地、只读、不上传。** +- transcript 默认不出网 +- 本地索引默认仅存必要字段(可选:是否缓存全文内容) +- 提供“敏感信息遮罩”(可选): + - 简单正则:token/key/password 等 +- 提示用户:会话内容可能包含敏感信息,导出/复制时注意 + +--- + +## 8. 非功能需求(Non-Functional Requirements) + +### 8.1 性能 +- 首次打开:列表可在 1s 内展示(允许先展示缓存,再后台增量刷新) +- 搜索:在 1k 会话量级可用(建立索引或增量缓存) +- 详情页:打开后 300ms 内渲染骨架屏,内容流式/分段加载 + +### 8.2 稳定性 +- 任一 provider 数据源损坏不影响整体(隔离失败) +- 扫描过程可中断/可重试 + +### 8.3 可观测性(可选) +- 本地日志:扫描耗时、解析失败原因、终端启动失败原因(便于 debug) + +--- + +## 9. 关键数据结构(建议) + +### 9.1 SessionMeta +- `providerId: "codex" | "claude" | string` +- `sessionId: string` +- `title?: string` +- `summary?: string` +- `projectDir?: string | null` +- `createdAt?: number` +- `lastActiveAt?: number` +- `sourcePath?: string` + +### 9.2 Message +- `role: "user" | "assistant" | "tool" | "system" | string` +- `content: string` +- `ts?: number` +- `raw?: any`(保留原始字段,便于兼容未来格式) + +--- + +## 10. 交互流程(UX Flows) + +### 10.1 Flow A:搜索并查看 +1) 打开 Session Manager → 看到列表 +2) 输入关键词搜索 → 命中会话 +3) 点击会话 → 进入详情 → 浏览内容 / 在会话内搜索 + +### 10.2 Flow B:复制恢复命令 +1) 列表或详情页点击“复制恢复命令” +2) toast 成功 → 用户粘贴到终端执行 + +### 10.3 Flow C:一键终端恢复 +1) 详情页点击“在终端恢复”(默认 Terminal) +2) 系统打开终端新窗口/新 tab +3) 自动执行:`cd projectDir && resumeCommand` +4) 失败 → toast 提示,并提供“复制命令”降级路径 + +--- + +## 11. 边界情况与降级策略 + +- 无法获取 projectDir:仍可恢复(只执行 resume),目录按钮置灰 +- 无法解析 transcript:列表仍显示,详情提示“无法解析”,可提供“打开原始文件路径” +- CLI 命令模板不匹配:允许 Settings 自定义模板;默认模板可更新 +- 终端权限问题(Automation):提示用户在系统设置中开启对应权限,并允许降级为复制命令 +- kitty 未开启 remote control:提示如何配置,降级为复制命令 + +--- + +## 12. 里程碑与交付(建议) + +### M1(核心可用) +- Provider 扫描:Codex / Claude +- 列表 + 详情(可读) +- 复制恢复命令 +- 复制目录(若可得) + +### M2(效率提升) +- 跨会话搜索、过滤/排序 +- 增量索引与文件监听(可选) +- “在 macOS Terminal 恢复” + +### M3(终端覆盖与可扩展) +- “在 kitty 恢复” +- 终端目标下拉与记忆 +- 插件化接口/扩展点文档 + +--- + +## 13. 后续功能候选(Backlog / Ideas) + +- 收藏/Pin 会话 +- 会话标签(项目/主题/状态) +- 会话摘要(本地生成) +- Fork 会话继续(避免污染原会话) +- 导出 Markdown/JSONL +- 按项目聚合(Repo 视图) +- 会话清理/归档(磁盘管理) + +--- diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 09b48b646..df5e47d25 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -13,6 +13,7 @@ mod prompt; mod provider; mod proxy; mod settings; +mod session_manager; pub mod skill; mod stream_check; mod usage; @@ -30,6 +31,7 @@ pub use prompt::*; pub use provider::*; pub use proxy::*; pub use settings::*; +pub use session_manager::*; pub use skill::*; pub use stream_check::*; pub use usage::*; diff --git a/src-tauri/src/commands/session_manager.rs b/src-tauri/src/commands/session_manager.rs new file mode 100644 index 000000000..4eb1994a8 --- /dev/null +++ b/src-tauri/src/commands/session_manager.rs @@ -0,0 +1,51 @@ +#![allow(non_snake_case)] + +use crate::session_manager; + +#[tauri::command] +pub async fn list_sessions() -> Result, String> { + let sessions = tauri::async_runtime::spawn_blocking(session_manager::scan_sessions) + .await + .map_err(|e| format!("Failed to scan sessions: {e}"))?; + Ok(sessions) +} + +#[tauri::command] +pub async fn get_session_messages( + providerId: String, + sourcePath: String, +) -> Result, String> { + let provider_id = providerId.clone(); + let source_path = sourcePath.clone(); + tauri::async_runtime::spawn_blocking(move || { + session_manager::load_messages(&provider_id, &source_path) + }) + .await + .map_err(|e| format!("Failed to load session messages: {e}"))? +} + +#[tauri::command] +pub async fn launch_session_terminal( + target: String, + command: String, + cwd: Option, + custom_config: Option, +) -> Result { + let command = command.clone(); + let target = target.clone(); + let cwd = cwd.clone(); + let custom_config = custom_config.clone(); + + tauri::async_runtime::spawn_blocking(move || { + session_manager::terminal::launch_terminal( + &target, + &command, + cwd.as_deref(), + custom_config.as_deref(), + ) + }) + .await + .map_err(|e| format!("Failed to launch terminal: {e}"))??; + + Ok(true) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 27abea279..de307b856 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ mod prompt_files; mod provider; mod provider_defaults; mod proxy; +mod session_manager; mod services; mod settings; mod store; @@ -933,6 +934,10 @@ pub fn run() { commands::stream_check_all_providers, commands::get_stream_check_config, commands::save_stream_check_config, + // Session manager + commands::list_sessions, + commands::get_session_messages, + commands::launch_session_terminal, commands::get_tool_versions, // Provider terminal commands::open_provider_terminal, diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs new file mode 100644 index 000000000..a91ba2787 --- /dev/null +++ b/src-tauri/src/session_manager/mod.rs @@ -0,0 +1,60 @@ +pub mod providers; +pub mod terminal; + +use serde::Serialize; +use std::path::Path; + +use providers::{claude, codex}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionMeta { + pub provider_id: String, + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_dir: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_active_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resume_command: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionMessage { + pub role: String, + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ts: Option, +} + +pub fn scan_sessions() -> Vec { + let mut sessions = Vec::new(); + sessions.extend(codex::scan_sessions()); + sessions.extend(claude::scan_sessions()); + + sessions.sort_by(|a, b| { + let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0); + let b_ts = b.last_active_at.or(b.created_at).unwrap_or(0); + b_ts.cmp(&a_ts) + }); + + sessions +} + +pub fn load_messages(provider_id: &str, source_path: &str) -> Result, String> { + let path = Path::new(source_path); + match provider_id { + "codex" => codex::load_messages(path), + "claude" => claude::load_messages(path), + _ => Err(format!("Unsupported provider: {provider_id}")), + } +} diff --git a/src-tauri/src/session_manager/providers/claude.rs b/src-tauri/src/session_manager/providers/claude.rs new file mode 100644 index 000000000..48101d3e0 --- /dev/null +++ b/src-tauri/src/session_manager/providers/claude.rs @@ -0,0 +1,194 @@ +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::config::get_claude_config_dir; +use crate::session_manager::{SessionMessage, SessionMeta}; + +use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary}; + +const PROVIDER_ID: &str = "claude"; + +pub fn scan_sessions() -> Vec { + let root = get_claude_config_dir().join("projects"); + let mut files = Vec::new(); + collect_jsonl_files(&root, &mut files); + + let mut sessions = Vec::new(); + for path in files { + if let Some(meta) = parse_session(&path) { + sessions.push(meta); + } + } + + sessions +} + +pub fn load_messages(path: &Path) -> Result, String> { + let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?; + let reader = BufReader::new(file); + let mut messages = Vec::new(); + + for line in reader.lines() { + let line = match line { + Ok(value) => value, + Err(_) => continue, + }; + let value: Value = match serde_json::from_str(&line) { + Ok(parsed) => parsed, + Err(_) => continue, + }; + + if value.get("isMeta").and_then(Value::as_bool) == Some(true) { + continue; + } + + let message = match value.get("message") { + Some(message) => message, + None => continue, + }; + + let role = message + .get("role") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + let content = message + .get("content") + .map(extract_text) + .unwrap_or_default(); + if content.trim().is_empty() { + continue; + } + + let ts = value + .get("timestamp") + .and_then(parse_timestamp_to_ms); + + messages.push(SessionMessage { role, content, ts }); + } + + Ok(messages) +} + +fn parse_session(path: &Path) -> Option { + if is_agent_session(path) { + return None; + } + + let file = File::open(path).ok()?; + let reader = BufReader::new(file); + + let mut session_id: Option = None; + let mut project_dir: Option = None; + let mut created_at: Option = None; + let mut last_active_at: Option = None; + let mut summary: Option = None; + + for line in reader.lines() { + let line = match line { + Ok(value) => value, + Err(_) => continue, + }; + let value: Value = match serde_json::from_str(&line) { + Ok(parsed) => parsed, + Err(_) => continue, + }; + + if session_id.is_none() { + session_id = value + .get("sessionId") + .and_then(Value::as_str) + .map(|s| s.to_string()); + } + + if project_dir.is_none() { + project_dir = value + .get("cwd") + .and_then(Value::as_str) + .map(|s| s.to_string()); + } + + if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) { + if created_at.is_none() { + created_at = Some(ts); + } + last_active_at = Some(ts); + } + + if value.get("isMeta").and_then(Value::as_bool) == Some(true) { + continue; + } + + let message = match value.get("message") { + Some(message) => message, + None => continue, + }; + + let text = message + .get("content") + .map(extract_text) + .unwrap_or_default(); + if text.trim().is_empty() { + continue; + } + summary = Some(text); + } + + let session_id = session_id.or_else(|| infer_session_id_from_filename(path)); + let session_id = session_id?; + + let title = project_dir + .as_deref() + .and_then(path_basename) + .map(|value| value.to_string()); + + let summary = summary.map(|text| truncate_summary(&text, 160)); + + Some(SessionMeta { + provider_id: PROVIDER_ID.to_string(), + session_id: session_id.clone(), + title, + summary, + project_dir, + created_at, + last_active_at, + source_path: Some(path.to_string_lossy().to_string()), + resume_command: Some(format!("claude --resume {session_id}")), + }) +} + +fn is_agent_session(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.starts_with("agent-")) + .unwrap_or(false) +} + +fn infer_session_id_from_filename(path: &Path) -> Option { + path.file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| stem.to_string()) +} + +fn collect_jsonl_files(root: &Path, files: &mut Vec) { + if !root.exists() { + return; + } + + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_jsonl_files(&path, files); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { + files.push(path); + } + } +} diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs new file mode 100644 index 000000000..4cff72915 --- /dev/null +++ b/src-tauri/src/session_manager/providers/codex.rs @@ -0,0 +1,202 @@ +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use serde_json::Value; +use regex::Regex; + +use crate::codex_config::get_codex_config_dir; +use crate::session_manager::{SessionMessage, SessionMeta}; + +use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary}; + +const PROVIDER_ID: &str = "codex"; + +pub fn scan_sessions() -> Vec { + let root = get_codex_config_dir().join("sessions"); + let mut files = Vec::new(); + collect_jsonl_files(&root, &mut files); + + let mut sessions = Vec::new(); + for path in files { + if let Some(meta) = parse_session(&path) { + sessions.push(meta); + } + } + + sessions +} + +pub fn load_messages(path: &Path) -> Result, String> { + let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?; + let reader = BufReader::new(file); + let mut messages = Vec::new(); + + for line in reader.lines() { + let line = match line { + Ok(value) => value, + Err(_) => continue, + }; + let value: Value = match serde_json::from_str(&line) { + Ok(parsed) => parsed, + Err(_) => continue, + }; + + if value.get("type").and_then(Value::as_str) != Some("response_item") { + continue; + } + + let payload = match value.get("payload") { + Some(payload) => payload, + None => continue, + }; + + if payload.get("type").and_then(Value::as_str) != Some("message") { + continue; + } + + let role = payload + .get("role") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + let content = payload + .get("content") + .map(extract_text) + .unwrap_or_default(); + if content.trim().is_empty() { + continue; + } + + let ts = value + .get("timestamp") + .and_then(parse_timestamp_to_ms); + + messages.push(SessionMessage { role, content, ts }); + } + + Ok(messages) +} + +fn parse_session(path: &Path) -> Option { + let file = File::open(path).ok()?; + let reader = BufReader::new(file); + + let mut session_id: Option = None; + let mut project_dir: Option = None; + let mut created_at: Option = None; + let mut last_active_at: Option = None; + let mut summary: Option = None; + + for line in reader.lines() { + let line = match line { + Ok(value) => value, + Err(_) => continue, + }; + let value: Value = match serde_json::from_str(&line) { + Ok(parsed) => parsed, + Err(_) => continue, + }; + + if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) { + if created_at.is_none() { + created_at = Some(ts); + } + last_active_at = Some(ts); + } + + if value.get("type").and_then(Value::as_str) == Some("session_meta") { + if let Some(payload) = value.get("payload") { + if session_id.is_none() { + session_id = payload + .get("id") + .and_then(Value::as_str) + .map(|s| s.to_string()); + } + if project_dir.is_none() { + project_dir = payload + .get("cwd") + .and_then(Value::as_str) + .map(|s| s.to_string()); + } + if let Some(ts) = payload.get("timestamp").and_then(parse_timestamp_to_ms) { + created_at.get_or_insert(ts); + } + } + continue; + } + + if value.get("type").and_then(Value::as_str) != Some("response_item") { + continue; + } + + let payload = match value.get("payload") { + Some(payload) => payload, + None => continue, + }; + + if payload.get("type").and_then(Value::as_str) != Some("message") { + continue; + } + + let text = payload + .get("content") + .map(extract_text) + .unwrap_or_default(); + if text.trim().is_empty() { + continue; + } + summary = Some(text); + } + + let session_id = session_id.or_else(|| infer_session_id_from_filename(path)); + let session_id = session_id?; + + let title = project_dir + .as_deref() + .and_then(path_basename) + .map(|value| value.to_string()); + + let summary = summary.map(|text| truncate_summary(&text, 160)); + + Some(SessionMeta { + provider_id: PROVIDER_ID.to_string(), + session_id: session_id.clone(), + title, + summary, + project_dir, + created_at, + last_active_at, + source_path: Some(path.to_string_lossy().to_string()), + resume_command: Some(format!("codex resume {session_id}")), + }) +} + +fn infer_session_id_from_filename(path: &Path) -> Option { + let file_name = path.file_name()?.to_string_lossy(); + let re = Regex::new( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", + ) + .ok()?; + re.find(&file_name).map(|mat| mat.as_str().to_string()) +} + +fn collect_jsonl_files(root: &Path, files: &mut Vec) { + if !root.exists() { + return; + } + + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_jsonl_files(&path, files); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { + files.push(path); + } + } +} diff --git a/src-tauri/src/session_manager/providers/mod.rs b/src-tauri/src/session_manager/providers/mod.rs new file mode 100644 index 000000000..2837bb916 --- /dev/null +++ b/src-tauri/src/session_manager/providers/mod.rs @@ -0,0 +1,3 @@ +pub mod claude; +pub mod codex; +mod utils; diff --git a/src-tauri/src/session_manager/providers/utils.rs b/src-tauri/src/session_manager/providers/utils.rs new file mode 100644 index 000000000..5e8c43877 --- /dev/null +++ b/src-tauri/src/session_manager/providers/utils.rs @@ -0,0 +1,77 @@ +use chrono::{DateTime, FixedOffset}; +use serde_json::Value; + +pub fn parse_timestamp_to_ms(value: &Value) -> Option { + let raw = value.as_str()?; + DateTime::parse_from_rfc3339(raw) + .ok() + .map(|dt: DateTime| dt.timestamp_millis()) +} + +pub fn extract_text(content: &Value) -> String { + match content { + Value::String(text) => text.to_string(), + Value::Array(items) => items + .iter() + .filter_map(|item| extract_text_from_item(item)) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n"), + Value::Object(map) => map + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + _ => String::new(), + } +} + +fn extract_text_from_item(item: &Value) -> Option { + if let Some(text) = item.get("text").and_then(|v| v.as_str()) { + return Some(text.to_string()); + } + + if let Some(text) = item.get("input_text").and_then(|v| v.as_str()) { + return Some(text.to_string()); + } + + if let Some(text) = item.get("output_text").and_then(|v| v.as_str()) { + return Some(text.to_string()); + } + + if let Some(content) = item.get("content") { + let text = extract_text(content); + if !text.is_empty() { + return Some(text); + } + } + + None +} + +pub fn truncate_summary(text: &str, max_chars: usize) -> String { + let trimmed = text.trim(); + if trimmed.is_empty() { + return String::new(); + } + if trimmed.chars().count() <= max_chars { + return trimmed.to_string(); + } + + let mut result = trimmed.chars().take(max_chars).collect::(); + result.push_str("..."); + result +} + +pub fn path_basename(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + let normalized = trimmed.trim_end_matches(|c| c == '/' || c == '\\'); + let last = normalized + .split(['/', '\\']) + .last() + .filter(|segment| !segment.is_empty())?; + Some(last.to_string()) +} diff --git a/src-tauri/src/session_manager/terminal/mod.rs b/src-tauri/src/session_manager/terminal/mod.rs new file mode 100644 index 000000000..799fa6552 --- /dev/null +++ b/src-tauri/src/session_manager/terminal/mod.rs @@ -0,0 +1,259 @@ +use std::process::Command; + +pub fn launch_terminal( + target: &str, + command: &str, + cwd: Option<&str>, + custom_config: Option<&str>, +) -> Result<(), String> { + if command.trim().is_empty() { + return Err("Resume command is empty".to_string()); + } + + if !cfg!(target_os = "macos") { + return Err("Terminal resume is only supported on macOS".to_string()); + } + + match target { + "terminal" => launch_macos_terminal(command, cwd), + "iTerm" | "iterm" => launch_iterm(command, cwd), + "ghostty" => launch_ghostty(command, cwd), + "kitty" => launch_kitty(command, cwd), + "wezterm" => launch_wezterm(command, cwd), + "alacritty" => launch_alacritty(command, cwd), + "custom" => launch_custom(command, cwd, custom_config), + _ => Err(format!("Unsupported terminal target: {target}")), + } +} + +fn launch_macos_terminal(command: &str, cwd: Option<&str>) -> Result<(), String> { + let full_command = build_shell_command(command, cwd); + let escaped = escape_osascript(&full_command); + let script = format!( + r#"tell application "Terminal" + activate + do script "{}" +end tell"#, + escaped + ); + + let status = Command::new("osascript") + .arg("-e") + .arg(script) + .status() + .map_err(|e| format!("Failed to launch Terminal: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("Terminal command execution failed".to_string()) + } +} + +fn launch_iterm(command: &str, cwd: Option<&str>) -> Result<(), String> { + let full_command = build_shell_command(command, cwd); + let escaped = escape_osascript(&full_command); + // iTerm2 AppleScript to create a new window and execute command + let script = format!( + r#"tell application "iTerm" + activate + create window with default profile + tell current session of current window + write text "{}" + end tell +end tell"#, + escaped + ); + + let status = Command::new("osascript") + .arg("-e") + .arg(script) + .status() + .map_err(|e| format!("Failed to launch iTerm: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("iTerm command execution failed".to_string()) + } +} + +fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> { + // Ghostty usage: open -na Ghostty --args +work-dir=... -e shell -c command + + // Using `open` to launch. + let mut args = vec!["-na", "Ghostty", "--args"]; + + // Ghostty uses --working-directory for working directory (or +work-dir, but --working-directory is standard in newer versions/compat) + // Note: The user's error output didn't show the working dir arg failure, so we assume flag is okay or we stick to compatible ones. + // Documentation says --working-directory is supported in CLI. + let work_dir_arg = if let Some(dir) = cwd { + format!("--working-directory={}", dir) + } else { + "".to_string() + }; + + if !work_dir_arg.is_empty() { + args.push(&work_dir_arg); + } + + // Command execution + args.push("-e"); + + // We pass the command and its arguments separately. + // The previous issue was passing the entire "cmd args" string as a single argument to -e, + // which led Ghostty to look for a binary named "cmd args". + // Splitting by whitespace allows Ghostty to see ["cmd", "args"]. + // Note: This assumes simple commands without quoted arguments containing spaces. + let full_command = build_shell_command(command, None); + for part in full_command.split_whitespace() { + args.push(part); + } + + let status = Command::new("open") + .args(&args) + .status() + .map_err(|e| format!("Failed to launch Ghostty: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("Failed to launch Ghostty. Make sure it is installed.".to_string()) + } +} + +fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> { + let full_command = build_shell_command(command, cwd); + + // 获取用户默认 shell + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + + let status = Command::new("open") + .arg("-na") + .arg("kitty") + .arg("--args") + .arg("-e") + .arg(&shell) + .arg("-l") + .arg("-c") + .arg(&full_command) + .status() + .map_err(|e| format!("Failed to launch Kitty: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("Failed to launch Kitty. Make sure it is installed.".to_string()) + } +} + +fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> { + // wezterm start --cwd ... -- command + // To invoke via `open`, we use `open -na "WezTerm" --args start ...` + + let full_command = build_shell_command(command, None); + + let mut args = vec!["-na", "WezTerm", "--args", "start"]; + + if let Some(dir) = cwd { + args.push("--cwd"); + args.push(dir); + } + + // Invoke shell to run the command string (to handle pipes, etc) + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + args.push("--"); + args.push(&shell); + args.push("-c"); + args.push(&full_command); + + let status = Command::new("open") + .args(&args) + .status() + .map_err(|e| format!("Failed to launch WezTerm: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("Failed to launch WezTerm.".to_string()) + } +} + +fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> { + // Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command + let full_command = build_shell_command(command, None); + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + + let mut args = vec!["-na", "Alacritty", "--args"]; + + if let Some(dir) = cwd { + args.push("--working-directory"); + args.push(dir); + } + + args.push("-e"); + args.push(&shell); + args.push("-c"); + args.push(&full_command); + + let status = Command::new("open") + .args(&args) + .status() + .map_err(|e| format!("Failed to launch Alacritty: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("Failed to launch Alacritty.".to_string()) + } +} + +fn launch_custom( + command: &str, + cwd: Option<&str>, + custom_config: Option<&str>, +) -> Result<(), String> { + let template = custom_config.ok_or("No custom terminal config provided")?; + + if template.trim().is_empty() { + return Err("Custom terminal command template is empty".to_string()); + } + + let cmd_str = command; + let dir_str = cwd.unwrap_or("."); + + let final_cmd_line = template + .replace("{command}", cmd_str) + .replace("{cwd}", dir_str); + + // Execute via sh -c + let status = Command::new("sh") + .arg("-c") + .arg(&final_cmd_line) + .status() + .map_err(|e| format!("Failed to execute custom terminal launcher: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("Custom terminal execution returned error code".to_string()) + } +} + +fn build_shell_command(command: &str, cwd: Option<&str>) -> String { + match cwd { + Some(dir) if !dir.trim().is_empty() => { + format!("cd {} && {}", shell_escape(dir), command) + } + _ => command.to_string(), + } +} + +fn shell_escape(value: &str) -> String { + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{}\"", escaped) +} + +fn escape_osascript(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} diff --git a/src/App.tsx b/src/App.tsx index b13116a24..a11487d2f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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("claude"); - const [currentView, setCurrentView] = useState("providers"); + const [activeApp, setActiveApp] = useState(getInitialApp); + const [currentView, setCurrentView] = useState(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() { ); + + case "sessions": + return ; default: return (
@@ -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")}
) : ( @@ -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" )} > @@ -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() { > + + ); +} diff --git a/src/components/sessions/SessionManagerPage.tsx b/src/components/sessions/SessionManagerPage.tsx new file mode 100644 index 000000000..c25291c36 --- /dev/null +++ b/src/components/sessions/SessionManagerPage.tsx @@ -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(null); + const messagesEndRef = useRef(null); + const messageRefs = useRef>(new Map()); + const [activeMessageIndex, setActiveMessageIndex] = useState( + null + ); + const [tocDialogOpen, setTocDialogOpen] = useState(false); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const searchInputRef = useRef(null); + + const [search, setSearch] = useState(""); + const [providerFilter, setProviderFilter] = useState("all"); + const [selectedKey, setSelectedKey] = useState(null); + const [terminalTarget, setTerminalTarget] = + useState("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 ( + +
+
+ {/* 主内容区域 - 左右分栏 */} +
+ {/* 左侧会话列表 */} + + + {isSearchOpen ? ( +
+ + 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); + } + }} + /> + +
+ ) : ( +
+
+ + {t("sessionManager.sessionList")} + + + {filteredSessions.length} + +
+
+ + + + + 搜索会话 + + + + + + + + + {t("common.refresh")} + +
+
+ )} +
+ + +
+ {isLoading ? ( +
+ +
+ ) : filteredSessions.length === 0 ? ( +
+ +

+ {t("sessionManager.noSessions")} +

+
+ ) : ( +
+ {filteredSessions.map((session) => { + const isSelected = + selectedKey !== null && + getSessionKey(session) === selectedKey; + + return ( + + ); + })} +
+ )} +
+
+
+
+ + {/* 右侧会话详情 */} + + {!selectedSession ? ( +
+ +

{t("sessionManager.selectSession")}

+
+ ) : ( + <> + {/* 详情头部 */} + +
+ {/* 左侧:会话信息 */} +
+
+ + + + + + + + {getProviderLabel(selectedSession.providerId, t)} + + +

+ {formatSessionTitle(selectedSession)} +

+
+ + {/* 元信息 */} +
+
+ + + {formatTimestamp( + selectedSession.lastActiveAt ?? + selectedSession.createdAt + )} + +
+ {selectedSession.projectDir && ( + + + + + +

+ {selectedSession.projectDir} +

+

+ 点击复制路径 +

+
+
+ )} +
+
+ + {/* 右侧:操作按钮组 */} +
+ {isMac() && ( + <> + + + + + + + + {selectedSession.resumeCommand + ? t("sessionManager.resumeTooltip", { + defaultValue: "在终端中恢复此会话", + }) + : t("sessionManager.noResumeCommand", { + defaultValue: "此会话无法恢复", + })} + + + + )} +
+
+ + {/* 恢复命令预览 */} + {selectedSession.resumeCommand && ( +
+
+ {selectedSession.resumeCommand} +
+ + + + + + {t("sessionManager.copyCommand", { + defaultValue: "复制命令", + })} + + +
+ )} +
+ + {/* 消息列表区域 */} + +
+ {/* 消息列表 */} + +
+
+ + + {t("sessionManager.conversationHistory", { + defaultValue: "对话记录", + })} + + + {messages.length} + +
+ + {isLoadingMessages ? ( +
+ +
+ ) : messages.length === 0 ? ( +
+ +

+ {t("sessionManager.emptySession")} +

+
+ ) : ( +
+ {messages.map((message, index) => ( + { + if (el) messageRefs.current.set(index, el); + }} + onCopy={(content) => + handleCopy( + content, + t("sessionManager.messageCopied", { + defaultValue: "已复制消息内容", + }) + ) + } + /> + ))} +
+
+ )} +
+ + + {/* 右侧目录 - 类似少数派 (大屏幕) */} + +
+ + {/* 浮动目录按钮 (小屏幕) */} + + + + )} + +
+
+
+ + ); +} diff --git a/src/components/sessions/SessionMessageItem.tsx b/src/components/sessions/SessionMessageItem.tsx new file mode 100644 index 000000000..805d50ba6 --- /dev/null +++ b/src/components/sessions/SessionMessageItem.tsx @@ -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 ( +
+ + + + + + {t("sessionManager.copyMessage", { + defaultValue: "复制内容", + })} + + +
+ + {getRoleLabel(message.role)} + + {message.ts && ( + + {formatTimestamp(message.ts)} + + )} +
+
+ {message.content} +
+
+ ); +} diff --git a/src/components/sessions/SessionToc.tsx b/src/components/sessions/SessionToc.tsx new file mode 100644 index 000000000..650bf5b3f --- /dev/null +++ b/src/components/sessions/SessionToc.tsx @@ -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 ( +
+
+
+ + 对话目录 +
+
+ +
+ {items.map((item, tocIndex) => ( + + ))} +
+
+
+ ); +} + +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 ( + + + + + onOpenChange(false)} + onEscapeKeyDown={() => onOpenChange(false)} + > + + + + 对话目录 + + + + + +
+
+ {items.map((item, tocIndex) => ( + + ))} +
+
+
+
+ ); +} diff --git a/src/components/sessions/utils.ts b/src/components/sessions/utils.ts new file mode 100644 index 000000000..0cb4aa233 --- /dev/null +++ b/src/components/sessions/utils.ts @@ -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) + ); +}; diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 000000000..32cf968d2 --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 09c5511e6..82fb3ff32 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -82,7 +82,7 @@ const SelectItem = React.forwardRef< , + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/src/hooks/useSessionSearch.ts b/src/hooks/useSessionSearch.ts new file mode 100644 index 000000000..e298a9a67 --- /dev/null +++ b/src/hooks/useSessionSearch.ts @@ -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; + +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(null); + // 索引 ID 到 session 的映射 + const sessionByIdxRef = useRef([]); + + // 初始化索引 + 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]); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e46202dd9..2fd797aba 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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:", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7df35b2bb..371496c54 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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": "プロバイダー切り替えリスナーの設定に失敗:", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 45ff5d7cf..fa7a1c88f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "设置供应商切换监听器失败:", diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index b027203f0..8e48d0151 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -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"; diff --git a/src/lib/api/sessions.ts b/src/lib/api/sessions.ts new file mode 100644 index 000000000..358d9abc1 --- /dev/null +++ b/src/lib/api/sessions.ts @@ -0,0 +1,30 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { SessionMessage, SessionMeta } from "@/types"; + +export const sessionsApi = { + async list(): Promise { + return await invoke("list_sessions"); + }, + + async getMessages( + providerId: string, + sourcePath: string + ): Promise { + return await invoke("get_session_messages", { providerId, sourcePath }); + }, + + async launchTerminal(options: { + target: string; + command: string; + cwd?: string | null; + customConfig?: string | null; + }): Promise { + const { target, command, cwd, customConfig } = options; + return await invoke("launch_session_terminal", { + target, + command, + cwd, + customConfig, + }); + }, +}; diff --git a/src/lib/query/queries.ts b/src/lib/query/queries.ts index 47a404166..2948e367c 100644 --- a/src/lib/query/queries.ts +++ b/src/lib/query/queries.ts @@ -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, @@ -132,3 +144,23 @@ export const useUsageQuery = ( lastQueriedAt: query.dataUpdatedAt || null, }; }; + +export const useSessionsQuery = () => { + return useQuery({ + queryKey: ["sessions"], + queryFn: async () => sessionsApi.list(), + staleTime: 30 * 1000, + }); +}; + +export const useSessionMessagesQuery = ( + providerId?: string, + sourcePath?: string, +) => { + return useQuery({ + queryKey: ["sessionMessages", providerId, sourcePath], + queryFn: async () => sessionsApi.getMessages(providerId!, sourcePath!), + enabled: Boolean(providerId && sourcePath), + staleTime: 30 * 1000, + }); +}; diff --git a/src/types.ts b/src/types.ts index 2416e3730..330ba38c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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