Skip to content

Commit d45c9cc

Browse files
MarkShawn2020claude
andcommitted
feat(chat): sidebar 重构 — Pinned/Recent/Import 分组 + Algolia 风格搜索
- Sidebar 列表分为 Pinned / Recent / Import 三组,Web tab 末尾才显示 Import 组 - 抽出 SectionHeader 共享组件,三组样式完全统一(label + count + chevron) - Pinned 三态 toggle:用户手动 pin / Claude app starred / 本地覆盖(unpinnedAppIds) - 搜索改为 ⌘K 触发 Algolia 风格 modal,支持键盘导航(↑↓/↵/ESC) - Web tab Import 组:Sync from local database(live API)+ Import from .zip - SessionItemButton:删除头部 inline pin 按钮,改为右侧 ⋯ 三点菜单(复用 SessionDropdownMenuItems),左侧加 circle bullet 表示 pin 状态 - GlobalHeader top nav:Chat 移到第一个 - ActivityCard 抽离到 home/,Chat 空状态 + PanelGrid 空状态共用 - Web sync 后端:Cookies SQLite + Keychain 解密 → claude.ai API → /api/organizations/{}/chat_conversations,并发 6 + 流式进度上报 - 修复 detail API 返回 message 没有 content 数组(只有 text 字段)的渲染兼容 - 修复 Claude desktop app 写 jsonl 顶层 type 与嵌套 message 的解析(read_session_head/get_session_messages/build_search_index/list_all_chats/read_session_usage 五处) - Web tab 与 App tab 无条件常驻,不依赖数据存在 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 194f69c commit d45c9cc

9 files changed

Lines changed: 546 additions & 236 deletions

File tree

src-tauri/src/lib.rs

Lines changed: 23 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,9 +1414,21 @@ async fn get_app_starred_session_ids() -> Result<Vec<String>, String> {
14141414
}
14151415
}
14161416

1417-
let resolved: Vec<String> = app_ids.into_iter()
1417+
let mut resolved: Vec<String> = app_ids.into_iter()
14181418
.filter_map(|aid| id_to_cli.get(&aid).cloned())
14191419
.collect();
1420+
1421+
// Also include web-starred conversation uuids cached by the latest
1422+
// sync_claude_web_conversations run. These are claude.ai web pins
1423+
// (separate from Code-tab starredIds) — we union them here so the
1424+
// frontend gets a single source of "what's app-starred".
1425+
let web_cache = get_lovstudio_dir().join("claude-web-starred.json");
1426+
if let Ok(content) = fs::read_to_string(&web_cache) {
1427+
if let Ok(arr) = serde_json::from_str::<Vec<String>>(&content) {
1428+
for uuid in arr { resolved.push(uuid); }
1429+
}
1430+
}
1431+
14201432
Ok(resolved)
14211433
})
14221434
.await
@@ -8476,35 +8488,16 @@ async fn sync_claude_web_conversations(app_handle: tauri::AppHandle) -> Result<W
84768488
.map_err(|e| format!("parse conversation list: {}", e))?;
84778489
eprintln!("[web-sync] got {} conversations from API", conv_list.len());
84788490

8479-
// PROBE: dump the first conversation's detail to /tmp so we can inspect
8480-
// the API schema regardless of fresh-skip logic.
8481-
if let Some(first_uuid) = conv_list.first().and_then(|c| c.get("uuid")).and_then(|v| v.as_str()) {
8482-
let probe_url = format!(
8483-
"https://claude.ai/api/organizations/{}/chat_conversations/{}?rendering_mode=raw",
8484-
org_id, first_uuid,
8485-
);
8486-
eprintln!("[web-sync] PROBE: GET {}", probe_url);
8487-
match client.get(&probe_url).header(reqwest::header::COOKIE, &cookie_header).send().await {
8488-
Ok(r) if r.status().is_success() => {
8489-
let text = r.text().await.unwrap_or_default();
8490-
let _ = std::fs::write("/tmp/lovcode-web-probe.json", &text);
8491-
eprintln!("[web-sync] PROBE: dumped {} bytes to /tmp/lovcode-web-probe.json", text.len());
8492-
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
8493-
if let Some(obj) = v.as_object() {
8494-
let keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
8495-
eprintln!("[web-sync] PROBE: top keys = {:?}", keys);
8496-
if let Some(cm) = obj.get("chat_messages").and_then(|v| v.as_array()) {
8497-
eprintln!("[web-sync] PROBE: chat_messages.len = {}", cm.len());
8498-
} else {
8499-
eprintln!("[web-sync] PROBE: NO chat_messages field");
8500-
}
8501-
}
8502-
}
8503-
}
8504-
Ok(r) => eprintln!("[web-sync] PROBE: HTTP {}", r.status()),
8505-
Err(e) => eprintln!("[web-sync] PROBE: send err: {}", e),
8506-
}
8507-
}
8491+
// Cache web starred conversation uuids to disk so the frontend pin sync
8492+
// can pick them up alongside Claude Code starredIds.
8493+
let web_starred: Vec<String> = conv_list.iter()
8494+
.filter(|c| c.get("is_starred").and_then(|v| v.as_bool()).unwrap_or(false))
8495+
.filter_map(|c| c.get("uuid").and_then(|v| v.as_str()).map(String::from))
8496+
.collect();
8497+
let cache_path = get_lovstudio_dir().join("claude-web-starred.json");
8498+
if let Some(parent) = cache_path.parent() { let _ = std::fs::create_dir_all(parent); }
8499+
let _ = std::fs::write(&cache_path, serde_json::to_string(&web_starred).unwrap_or_else(|_| "[]".into()));
8500+
eprintln!("[web-sync] cached {} web-starred conversations", web_starred.len());
85088501

85098502
// 5. Prepare project dir
85108503
let project_id = "-claude-ai".to_string();

src/components/GlobalHeader/GlobalHeader.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ export function GlobalHeader({
8686
</div>
8787
{/* Center: menu group */}
8888
<div className="flex-1 flex items-center justify-center gap-0.5" data-tauri-drag-region>
89+
<NavButton
90+
isActive={primaryFeature === "chat"}
91+
onClick={() => handleMainNavClick("chat")}
92+
icon={<CounterClockwiseClockIcon className="w-4 h-4" />}
93+
label="Chat"
94+
/>
8995
<NavButton
9096
isActive={primaryFeature === "workspace"}
9197
onClick={() => handleMainNavClick("workspace")}
@@ -98,12 +104,6 @@ export function GlobalHeader({
98104
icon={<LayersIcon className="w-4 h-4" />}
99105
label="Configuration"
100106
/>
101-
<NavButton
102-
isActive={primaryFeature === "chat"}
103-
onClick={() => handleMainNavClick("chat")}
104-
icon={<CounterClockwiseClockIcon className="w-4 h-4" />}
105-
label="Chat"
106-
/>
107107
<NavButton
108108
isActive={primaryFeature?.startsWith("kb-") ?? false}
109109
onClick={() => handleMainNavClick("kb-distill")}

src/components/PanelGrid/PanelGrid.tsx

Lines changed: 3 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import type { LayoutNode } from "../../views/Workspace/types";
1010
import { TERMINAL_OPTIONS, type ProjectOption } from "../ui/new-terminal-button";
1111
import { SlashCommandMenu, type CommandItem } from "../ui/slash-command-menu";
1212
import { useInvokeQuery, useQueryClient } from "../../hooks";
13-
import { ActivityHeatmap } from "../home";
13+
import { ActivityCard } from "../home";
1414
import { LLM_PROVIDER_PRESETS } from "../../constants";
15-
import type { LocalCommand, CodexCommand, Project, Session, ClaudeSettings, MaasProvider } from "../../types";
15+
import type { LocalCommand, CodexCommand, ClaudeSettings, MaasProvider } from "../../types";
1616
import {
1717
DropdownMenu,
1818
DropdownMenuTrigger,
@@ -215,16 +215,6 @@ export function PanelGrid({
215215
"list_codex_commands"
216216
);
217217

218-
// Fetch activity data for the heatmap shown in the empty state
219-
const { data: activityProjects = [] } = useInvokeQuery<Project[]>(["projects"], "list_projects");
220-
const { data: activitySessions = [] } = useInvokeQuery<Session[]>(["sessions"], "list_all_sessions");
221-
const { data: activityStats } = useInvokeQuery<{
222-
daily: Record<string, number>;
223-
hourly: Record<string, number>;
224-
detailed: Record<string, number>;
225-
}>(["activityStats"], "get_activity_stats");
226-
const totalMessages = activitySessions.reduce((sum, s) => sum + s.message_count, 0);
227-
228218
// Claude settings -> active provider + model (for the prompt-box dropdown)
229219
const { data: claudeSettings } = useInvokeQuery<ClaudeSettings>(["settings"], "get_settings");
230220
const { data: maasRegistry = [] } = useInvokeQuery<MaasProvider[]>(
@@ -435,25 +425,7 @@ export function PanelGrid({
435425
) : null}
436426

437427
{/* Activity heatmap + stats */}
438-
{activityStats && (
439-
<div className="w-full shrink-0 bg-card/50 rounded-2xl p-3 border border-border/40 overflow-hidden">
440-
<ActivityHeatmap
441-
daily={activityStats.daily}
442-
detailed={activityStats.detailed}
443-
/>
444-
<div className="flex items-center gap-6 mt-2 pt-2 border-t border-border/40 text-xs text-muted-foreground">
445-
<span>
446-
<strong className="text-foreground font-serif">{activityProjects.length}</strong> workspaces
447-
</span>
448-
<span>
449-
<strong className="text-foreground font-serif">{activitySessions.length}</strong> sessions
450-
</span>
451-
<span>
452-
<strong className="text-foreground font-serif">{totalMessages}</strong> messages
453-
</span>
454-
</div>
455-
</div>
456-
)}
428+
<ActivityCard />
457429

458430

459431
{/* Super prompt box - separated terminal-style input + controls */}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { Project, Session } from "../../types";
2+
import { useInvokeQuery } from "../../hooks";
3+
import { ActivityHeatmap } from "./ActivityHeatmap";
4+
5+
/**
6+
* Activity heatmap card with workspace / session / message stats.
7+
*
8+
* Used as an empty-state placeholder across the app (PanelGrid, ProjectList,
9+
* etc.) to give the user something useful to look at while no specific item
10+
* is selected.
11+
*/
12+
export function ActivityCard({ className = "" }: { className?: string }) {
13+
const { data: projects = [] } = useInvokeQuery<Project[]>(["projects"], "list_projects");
14+
const { data: sessions = [] } = useInvokeQuery<Session[]>(["sessions"], "list_all_sessions");
15+
const { data: stats } = useInvokeQuery<{
16+
daily: Record<string, number>;
17+
hourly: Record<string, number>;
18+
detailed: Record<string, number>;
19+
}>(["activityStats"], "get_activity_stats");
20+
21+
if (!stats) return null;
22+
const totalMessages = sessions.reduce((sum, s) => sum + s.message_count, 0);
23+
24+
return (
25+
<div className={`w-full shrink-0 bg-card/50 rounded-2xl p-3 border border-border/40 overflow-hidden ${className}`}>
26+
<ActivityHeatmap daily={stats.daily} detailed={stats.detailed} />
27+
<div className="flex items-center gap-6 mt-2 pt-2 border-t border-border/40 text-xs text-muted-foreground">
28+
<span>
29+
<strong className="text-foreground font-serif">{projects.length}</strong> workspaces
30+
</span>
31+
<span>
32+
<strong className="text-foreground font-serif">{sessions.length}</strong> sessions
33+
</span>
34+
<span>
35+
<strong className="text-foreground font-serif">{totalMessages}</strong> messages
36+
</span>
37+
</div>
38+
</div>
39+
);
40+
}

src/components/home/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { ActivityHeatmap } from "./ActivityHeatmap";
2+
export { ActivityCard } from "./ActivityCard";
23
export { RecentActivity } from "./RecentActivity";
34
export { QuickActions } from "./QuickActions";
45
export { CommandTrendChart } from "./CommandTrendChart";

src/components/shared/SessionMenuItems.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export interface SessionMenuConfig {
2828
/** Archive this session and every session after it in the visible list. Count is used for the label. */
2929
onArchiveAllAfter?: () => void;
3030
archiveAfterCount?: number;
31+
/** Pin override: when supplied, takes precedence over the local useSessionPin atom.
32+
* Use this to integrate with the effective pin set (which can include external
33+
* sources like Claude app starredIds). */
34+
isPinnedOverride?: boolean;
35+
onTogglePinOverride?: () => void;
3136
}
3237

3338
// Shared handlers
@@ -87,11 +92,15 @@ export function SessionDropdownMenuItems({
8792
onResume,
8893
onArchiveAllAfter,
8994
archiveAfterCount,
95+
isPinnedOverride,
96+
onTogglePinOverride,
9097
}: SessionMenuConfig) {
9198
const { handleReveal, handleOpenInEditor, handleCopyPath, handleCopySessionId, handleCopyResumeCommand } =
9299
useSessionMenuHandlers(projectId, sessionId);
93100
const { isArchived, toggleArchived } = useSessionArchive(sessionId);
94-
const { isPinned, togglePinned } = useSessionPin(sessionId);
101+
const { isPinned: localIsPinned, togglePinned: localTogglePinned } = useSessionPin(sessionId);
102+
const isPinned = isPinnedOverride ?? localIsPinned;
103+
const togglePinned = onTogglePinOverride ?? localTogglePinned;
95104

96105
return (
97106
<>
@@ -180,11 +189,15 @@ export function SessionContextMenuItems({
180189
onResume,
181190
onArchiveAllAfter,
182191
archiveAfterCount,
192+
isPinnedOverride,
193+
onTogglePinOverride,
183194
}: SessionMenuConfig) {
184195
const { handleReveal, handleOpenInEditor, handleCopyPath, handleCopySessionId, handleCopyResumeCommand } =
185196
useSessionMenuHandlers(projectId, sessionId);
186197
const { isArchived, toggleArchived } = useSessionArchive(sessionId);
187-
const { isPinned, togglePinned } = useSessionPin(sessionId);
198+
const { isPinned: localIsPinned, togglePinned: localTogglePinned } = useSessionPin(sessionId);
199+
const isPinned = isPinnedOverride ?? localIsPinned;
200+
const togglePinned = onTogglePinOverride ?? localTogglePinned;
188201

189202
return (
190203
<>

src/store/atoms/chat.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ export const showArchivedSessionsAtom = atomWithStorage("lovcode:sidebar:showArc
2424
// but stored locally — Claude app does not expose pin state in claude-code-sessions JSON.
2525
export const pinnedSessionIdsAtom = atomWithStorage<string[]>("lovcode:sidebar:pinnedSessionIds", []);
2626

27+
// Local override: ids that were starred upstream by Claude app/web but the user
28+
// unpinned in lovcode. Subtracted from the effective pinned set so toggling a
29+
// Claude-starred session here actually un-pins it locally (without writing back
30+
// to claude.ai). Cleared automatically if upstream un-stars the same id.
31+
export const unpinnedAppIdsAtom = atomWithStorage<string[]>("lovcode:sidebar:unpinnedAppIds", []);
32+
33+
// Whether the Pinned section in the sidebar is collapsed
34+
export const pinnedCollapsedAtom = atomWithStorage("lovcode:sidebar:pinnedCollapsed", false);
35+
export const recentCollapsedAtom = atomWithStorage("lovcode:sidebar:recentCollapsed", false);
36+
export const importCollapsedAtom = atomWithStorage("lovcode:sidebar:importCollapsed", false);
37+
2738
// ProjectList
2839
export const chatViewModeAtom = atomWithStorage<"projects" | "sessions" | "chats">("lovcode:chatViewMode", "projects");
2940
export const allProjectsSortByAtom = atomWithStorage<"name" | "recent" | "sessions">("lovcode:allProjects:sortBy", "recent");

src/store/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export {
1616
sidebarViewModeAtom, type SidebarViewMode,
1717
archivedSessionIdsAtom, showArchivedSessionsAtom,
1818
pinnedSessionIdsAtom,
19+
unpinnedAppIdsAtom,
20+
pinnedCollapsedAtom,
21+
recentCollapsedAtom,
22+
importCollapsedAtom,
1923
} from "./atoms/chat";
2024

2125
// Settings atoms

0 commit comments

Comments
 (0)