diff --git a/crates/hk-core/src/adapter/claude.rs b/crates/hk-core/src/adapter/claude.rs index 28bc08b4..bfee325b 100644 --- a/crates/hk-core/src/adapter/claude.rs +++ b/crates/hk-core/src/adapter/claude.rs @@ -41,6 +41,34 @@ impl ClaudeAdapter { let content = std::fs::read_to_string(path).ok()?; serde_json::from_str(&content).ok() } + + /// The real working directory a Claude session ran in, read from the + /// first `*.jsonl` transcript in `project_dir`. Every transcript line + /// carries a `"cwd"` field; we read lazily (line by line) and return the + /// first parseable one, so a multi-MB transcript costs one line. Returns + /// `None` when there is no transcript or no `cwd` — caller treats that as + /// an unknown owner. + fn session_cwd(project_dir: &Path) -> Option { + use std::io::BufRead; + let entries = std::fs::read_dir(project_dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "jsonl") { + let Ok(file) = std::fs::File::open(&path) else { + continue; + }; + for line in std::io::BufReader::new(file).lines().map_while(Result::ok) { + if let Some(cwd) = serde_json::from_str::(&line) + .ok() + .and_then(|v| v.get("cwd").and_then(|c| c.as_str()).map(PathBuf::from)) + { + return Some(cwd); + } + } + } + } + None + } } impl AgentAdapter for ClaudeAdapter { @@ -181,20 +209,26 @@ impl AgentAdapter for ClaudeAdapter { files } - fn global_memory_files(&self) -> Vec { - // ~/.claude/projects/*/memory/*.md — outer level iterates project - // dirs (no extension filter); inner level reuses the helper. + fn external_project_memory(&self) -> Vec<(Option, Vec)> { + // ~/.claude/projects//memory/*.md — one group per project + // dir, tagged with the real cwd from its session transcript. The + // encoded dir name is lossy (`/`, space and `~` all collapse to `-`), + // so we read the cwd instead of decoding the name. Single pass. let projects_dir = self.base_dir().join("projects"); - let mut files = Vec::new(); - if let Ok(entries) = std::fs::read_dir(&projects_dir) { - for entry in entries.flatten() { - let memory_dir = entry.path().join("memory"); - if memory_dir.is_dir() { - files.extend(super::files_with_ext(&memory_dir, "md")); - } + let Ok(entries) = std::fs::read_dir(&projects_dir) else { + return vec![]; + }; + let mut groups = Vec::new(); + for entry in entries.flatten() { + let dir = entry.path(); + let files: Vec = + super::files_with_ext(&dir.join("memory"), "md").collect(); + if files.is_empty() { + continue; } + groups.push((Self::session_cwd(&dir), files)); } - files + groups } fn global_settings_files(&self) -> Vec { @@ -383,6 +417,45 @@ mod tests { assert_eq!(adapter.name(), "claude"); } + #[test] + fn external_project_memory_groups_files_with_session_cwd() { + use std::fs; + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path(); + + // Project A: has a transcript → owner cwd is known. + let a = home.join(".claude/projects/-Users-zoe-Demo-Proj"); + fs::create_dir_all(a.join("memory")).unwrap(); + fs::write(a.join("s.jsonl"), "{\"cwd\":\"/Users/zoe/Demo/Proj\"}\n").unwrap(); + fs::write(a.join("memory/note.md"), "x").unwrap(); + + // Project B: memory but NO transcript → owner unknown (None). + let b = home.join(".claude/projects/-orphan"); + fs::create_dir_all(b.join("memory")).unwrap(); + fs::write(b.join("memory/orphan.md"), "y").unwrap(); + + let adapter = ClaudeAdapter::with_home(home.to_path_buf()); + let groups = adapter.external_project_memory(); + + // One group per project dir that actually has memory. + assert_eq!(groups.len(), 2); + + let known = groups + .iter() + .find(|(_, f)| f.iter().any(|p| p.ends_with("note.md"))) + .unwrap(); + assert_eq!( + known.0.as_deref(), + Some(std::path::Path::new("/Users/zoe/Demo/Proj")) + ); + + let orphan = groups + .iter() + .find(|(_, f)| f.iter().any(|p| p.ends_with("orphan.md"))) + .unwrap(); + assert_eq!(orphan.0, None); + } + #[test] fn test_claude_detect_with_dir() { let dir = TempDir::new().unwrap(); diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index 88e8f4a5..0ccff4e3 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -222,6 +222,21 @@ pub trait AgentAdapter: Send + Sync { vec![] } + /// Per-project memory stored OUTSIDE the project tree, grouped by the + /// project `cwd` that owns it. Claude keeps this at + /// `~/.claude/projects//memory/`, keyed by the session cwd + /// rather than inside the project. + /// + /// Each entry is `(owner_cwd, files)`. The scanner assigns + /// `ConfigScope::Project` when `owner_cwd` is `Some` and matches a + /// registered project, and `ConfigScope::Global` otherwise — including + /// when the owner can't be determined (`None`). An agent implements + /// EITHER this or `global_memory_files` for a given store, never both + /// over the same files, so the scanner never double-lists. + fn external_project_memory(&self) -> Vec<(Option, Vec)> { + vec![] + } + /// Global settings files (absolute paths, e.g. ~/.claude/settings.json) fn global_settings_files(&self) -> Vec { vec![] @@ -504,6 +519,7 @@ mod tests { for a in &adapters { let _ = a.global_rules_files(); let _ = a.global_memory_files(); + let _ = a.external_project_memory(); let _ = a.global_settings_files(); let _ = a.global_subagent_files(); let _ = a.project_rules_patterns(); diff --git a/crates/hk-core/src/scanner.rs b/crates/hk-core/src/scanner.rs index 99c58693..6e242593 100644 --- a/crates/hk-core/src/scanner.rs +++ b/crates/hk-core/src/scanner.rs @@ -1986,6 +1986,30 @@ pub fn scan_agent_configs( ) -> Vec { let mut configs = Vec::new(); + // --- External project memory (stored outside the project tree) --- + // Agents like Claude keep per-project memory in a global location keyed by + // the session cwd. Attribute each group to its registered project, else + // fall back to Global (unregistered project, or cwd undeterminable). This + // is the ONLY producer of such memory rows — those adapters do not also + // return it from `global_memory_files`, so there is nothing to de-dupe. + for (owner_cwd, files) in adapter.external_project_memory() { + let scope = owner_cwd + .as_ref() + .and_then(|cwd| projects.iter().find(|(_, path)| Path::new(path) == cwd.as_path())) + .map(|(name, path)| ConfigScope::Project { + name: name.clone(), + path: path.clone(), + }) + .unwrap_or(ConfigScope::Global); + for path in &files { + if let Some(cf) = + stat_config_file(path, adapter.name(), ConfigCategory::Memory, scope.clone()) + { + configs.push(cf); + } + } + } + // --- Global files --- let global_groups: [(ConfigCategory, Vec); 5] = [ (ConfigCategory::Rules, adapter.global_rules_files()), @@ -2847,6 +2871,50 @@ mod config_tests { use crate::adapter::claude::ClaudeAdapter; use std::fs; + #[test] + fn test_scan_agent_configs_claude_external_memory_scoping() { + use crate::adapter::claude::ClaudeAdapter; + use std::fs; + + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path(); + + // Registered project → Project scope. + let reg = home.join(".claude/projects/-Users-zoe-Demo-Proj"); + fs::create_dir_all(reg.join("memory")).unwrap(); + fs::write(reg.join("s.jsonl"), "{\"cwd\":\"/Users/zoe/Demo/Proj\"}\n").unwrap(); + fs::write(reg.join("memory/note.md"), "x").unwrap(); + + // Unregistered project → Global scope (behavior preserved). + let unreg = home.join(".claude/projects/-Users-zoe-Other"); + fs::create_dir_all(unreg.join("memory")).unwrap(); + fs::write(unreg.join("s.jsonl"), "{\"cwd\":\"/Users/zoe/Other\"}\n").unwrap(); + fs::write(unreg.join("memory/misc.md"), "y").unwrap(); + + let adapter = ClaudeAdapter::with_home(home.to_path_buf()); + let projects = vec![("Demo".to_string(), "/Users/zoe/Demo/Proj".to_string())]; + let configs = scan_agent_configs(&adapter, &projects); + + let note = configs.iter().find(|c| c.file_name == "note.md").unwrap(); + assert!( + matches!(¬e.scope, ConfigScope::Project { name, .. } if name == "Demo"), + "note.md should be Project-scoped, got {:?}", + note.scope + ); + // Not duplicated across passes. + assert_eq!( + configs.iter().filter(|c| c.file_name == "note.md").count(), + 1 + ); + + let misc = configs.iter().find(|c| c.file_name == "misc.md").unwrap(); + assert!( + matches!(misc.scope, ConfigScope::Global), + "misc.md should stay Global, got {:?}", + misc.scope + ); + } + #[test] fn test_scan_agent_configs_global_files() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/components/agents/__tests__/config-section-memory.test.tsx b/src/components/agents/__tests__/config-section-memory.test.tsx new file mode 100644 index 00000000..58d2bf35 --- /dev/null +++ b/src/components/agents/__tests__/config-section-memory.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { AgentConfigFile } from "@/lib/types"; +import { ConfigSection } from "../config-section"; + +function mem(path: string, scope: AgentConfigFile["scope"]): AgentConfigFile { + return { + path, + agent: "claude", + category: "memory", + scope, + file_name: path.slice(path.lastIndexOf("/") + 1), + size_bytes: 100, + modified_at: null, + is_dir: false, + exists: true, + }; +} +beforeEach(() => localStorage.clear()); + +describe("ConfigSection memory grouping", () => { + it("renders one header per storage dir; project name for project scope, raw path for global", () => { + render( + , + ); + expect(screen.getByText("CS")).toBeInTheDocument(); + // Project group header now also shows its storage path (not just the name). + expect( + screen.getByText("/h/.claude/projects/-cs/memory"), + ).toBeInTheDocument(); + expect( + screen.getByText("/h/.claude/projects/-dl/memory"), + ).toBeInTheDocument(); + // Never decoded into a fake project path. + expect(screen.queryByText("/h/dl")).not.toBeInTheDocument(); + // Rows hide their per-row scope path (now shown once in the group header): + // the project file's scope path (/real/CS) must not render on the row. + expect(screen.queryByText("/real/CS")).not.toBeInTheDocument(); + // Badge now lives on the rows (not the header): each file row shows its scope badge. + expect(screen.getByText("Project")).toBeInTheDocument(); + expect(screen.getByText("Global")).toBeInTheDocument(); + }); +}); diff --git a/src/components/agents/__tests__/memory-grouping.test.ts b/src/components/agents/__tests__/memory-grouping.test.ts new file mode 100644 index 00000000..df6cc9e8 --- /dev/null +++ b/src/components/agents/__tests__/memory-grouping.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import type { AgentConfigFile } from "@/lib/types"; +import { groupMemoryFiles } from "../memory-grouping"; + +function file( + path: string, + size: number, + scope: AgentConfigFile["scope"], +): AgentConfigFile { + return { + path, + agent: "claude", + category: "memory", + scope, + file_name: path.slice( + Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1, + ), + size_bytes: size, + modified_at: null, + is_dir: false, + exists: true, + }; +} +const GLOBAL = { type: "global" } as const; + +describe("groupMemoryFiles", () => { + it("groups by storage directory, sums bytes, sorts files by name", () => { + const groups = groupMemoryFiles([ + file("/h/.claude/projects/-a/memory/two.md", 50, GLOBAL), + file("/h/.claude/projects/-a/memory/one.md", 100, GLOBAL), + file("/h/.claude/projects/-b/memory/three.md", 200, GLOBAL), + ]); + expect(groups).toHaveLength(2); + const a = groups.find( + (g) => g.storePath === "/h/.claude/projects/-a/memory", + )!; + expect(a.files.map((f) => f.file_name)).toEqual(["one.md", "two.md"]); + expect(a.totalBytes).toBe(150); + expect(a.projectName).toBeNull(); + expect(a.storePath).toBe("/h/.claude/projects/-a/memory"); + }); + + it("puts project groups first with their project name", () => { + const proj = { type: "project", name: "CS", path: "/real/CS" } as const; + const groups = groupMemoryFiles([ + file("/h/.claude/projects/-z/memory/g.md", 10, GLOBAL), + file("/h/.claude/projects/-cs/memory/p.md", 10, proj), + ]); + expect(groups[0].projectName).toBe("CS"); + expect(groups[1].projectName).toBeNull(); + }); + + it("groups by directory on Windows-style backslash paths", () => { + const groups = groupMemoryFiles([ + file("C:\\Users\\z\\.claude\\projects\\-a\\memory\\one.md", 100, GLOBAL), + file("C:\\Users\\z\\.claude\\projects\\-a\\memory\\two.md", 50, GLOBAL), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].storePath).toBe( + "C:\\Users\\z\\.claude\\projects\\-a\\memory", + ); + expect(groups[0].files.map((f) => f.file_name)).toEqual([ + "one.md", + "two.md", + ]); + }); + + it("returns [] for empty input", () => { + expect(groupMemoryFiles([])).toEqual([]); + }); +}); diff --git a/src/components/agents/config-file-entry.tsx b/src/components/agents/config-file-entry.tsx index 88e6593f..612da450 100644 --- a/src/components/agents/config-file-entry.tsx +++ b/src/components/agents/config-file-entry.tsx @@ -15,11 +15,24 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useScrollPassthrough } from "@/hooks/use-scroll-passthrough"; import { openDirectoryPicker, openFilePicker } from "@/lib/dialog"; +import { formatBytes } from "@/lib/format"; import { isDesktop } from "@/lib/transport"; import type { AgentConfigFile } from "@/lib/types"; import { useAgentConfigStore } from "@/stores/agent-config-store"; -export function ConfigFileEntry({ file }: { file: AgentConfigFile }) { +export function ConfigFileEntry({ + file, + hideScopePath = false, + inset = false, +}: { + file: AgentConfigFile; + /** When true, hide only the scope path (the badge still shows). Used by the grouped MEMORY view where the path lives on the group header. */ + hideScopePath?: boolean; + /** When true, indent the row content via extra left padding while keeping the + * button full-width (so the hover highlight fills to the left edge). Used by + * the grouped MEMORY view. */ + inset?: boolean; +}) { const { t } = useTranslation("agents"); const { t: tc } = useTranslation("common"); const expandedFiles = useAgentConfigStore((s) => s.expandedFiles); @@ -95,10 +108,7 @@ export function ConfigFileEntry({ file }: { file: AgentConfigFile }) { : file.scope.type === "global" ? file.path.slice(0, file.path.lastIndexOf(file.file_name)) : file.scope.path; - const sizeLabel = - file.size_bytes < 1024 - ? `${file.size_bytes} B` - : `${(file.size_bytes / 1024).toFixed(1)} KB`; + const sizeLabel = formatBytes(file.size_bytes); return (
@@ -106,7 +116,8 @@ export function ConfigFileEntry({ file }: { file: AgentConfigFile }) { ref={buttonRef} onClick={() => toggleFile(file.path)} className={clsx( - "flex w-full items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-accent/30", + "flex w-full items-center justify-between pr-4 py-2.5 text-left transition-colors hover:bg-accent/30", + inset ? "pl-8" : "pl-4", isExpanded && "bg-accent/20", highlight && "ring-2 ring-primary ring-inset bg-primary/5 transition-all", @@ -143,9 +154,11 @@ export function ConfigFileEntry({ file }: { file: AgentConfigFile }) { {tc("scope.project")} ))} - - {scopePath} - + {!hideScopePath && ( + + {scopePath} + + )}
{!file.is_dir && ( diff --git a/src/components/agents/config-section.tsx b/src/components/agents/config-section.tsx index 4cac48da..c3e98d26 100644 --- a/src/components/agents/config-section.tsx +++ b/src/components/agents/config-section.tsx @@ -9,11 +9,14 @@ import { Settings, Workflow, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { useCollapsibleState } from "@/hooks/use-collapsible-state"; import type { AgentConfigFile, ConfigCategory } from "@/lib/types"; import { useAgentConfigStore } from "@/stores/agent-config-store"; import { ConfigFileEntry } from "./config-file-entry"; +import { MemoryGroup } from "./memory-group"; +import { groupMemoryFiles } from "./memory-grouping"; const CATEGORY_ICONS: Record = { rules: FileText, @@ -45,40 +48,18 @@ export function ConfigSection({ const storageKey = agentName ? collapseStorageKey(agentName, category) : null; const pendingFocusFile = useAgentConfigStore((s) => s.pendingFocusFile); - const [collapsed, setCollapsed] = useState(() => { - if (!storageKey) return false; - return localStorage.getItem(storageKey) === "1"; - }); - - // When the user switches agents the storageKey changes; rehydrate from disk. - useEffect(() => { - if (!storageKey) return; - setCollapsed(localStorage.getItem(storageKey) === "1"); - }, [storageKey]); + const { collapsed, setCollapsed, toggle } = useCollapsibleState(storageKey); // If the user navigates here with a focus target (e.g. clicked a file in the // Overview's Agent Activity widget), and that file lives in this section, - // force-open it. We also clear the persisted collapse state so the section - // doesn't snap shut once the focus signal is consumed — the user can - // re-collapse with the chevron if they want. + // force-open it. Setting collapsed=false also clears the persisted state so + // the section doesn't snap shut once the focus signal is consumed — the user + // can re-collapse with the chevron if they want. const containsFocusFile = pendingFocusFile != null && files.some((f) => f.path === pendingFocusFile); useEffect(() => { - if (!containsFocusFile || !collapsed) return; - setCollapsed(false); - if (storageKey) localStorage.removeItem(storageKey); - }, [containsFocusFile, collapsed, storageKey]); - - const toggle = () => { - setCollapsed((prev) => { - const next = !prev; - if (storageKey) { - if (next) localStorage.setItem(storageKey, "1"); - else localStorage.removeItem(storageKey); - } - return next; - }); - }; + if (containsFocusFile && collapsed) setCollapsed(false); + }, [containsFocusFile, collapsed, setCollapsed]); if (files.length === 0) return null; const Icon = CATEGORY_ICONS[category] ?? Settings; @@ -104,9 +85,17 @@ export function ConfigSection({ {!collapsed && (
- {files.map((file) => ( - - ))} + {category === "memory" + ? groupMemoryFiles(files).map((group) => ( + + )) + : files.map((file) => ( + + ))}
)} diff --git a/src/components/agents/memory-group.tsx b/src/components/agents/memory-group.tsx new file mode 100644 index 00000000..09d4322c --- /dev/null +++ b/src/components/agents/memory-group.tsx @@ -0,0 +1,73 @@ +import { ChevronDown, ChevronRight, Folder } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useCollapsibleState } from "@/hooks/use-collapsible-state"; +import { formatBytes } from "@/lib/format"; +import { ConfigFileEntry } from "./config-file-entry"; +import type { MemoryGroup as MemoryGroupData } from "./memory-grouping"; + +/** localStorage key for one memory group's collapse state. */ +function groupCollapseKey(agent: string, storePath: string): string { + return `agent-detail-collapse:memory-group:${agent}:${storePath}`; +} + +export function MemoryGroup({ + group, + agentName, +}: { + group: MemoryGroupData; + agentName?: string; +}) { + const { t } = useTranslation("agents"); + const storageKey = agentName + ? groupCollapseKey(agentName, group.storePath) + : null; + const { collapsed, toggle } = useCollapsibleState(storageKey); + + const Chevron = collapsed ? ChevronRight : ChevronDown; + const isProject = group.projectName != null; + + return ( +
+ + {!collapsed && + group.files.map((file) => ( + + ))} +
+ ); +} diff --git a/src/components/agents/memory-grouping.ts b/src/components/agents/memory-grouping.ts new file mode 100644 index 00000000..933097a1 --- /dev/null +++ b/src/components/agents/memory-grouping.ts @@ -0,0 +1,58 @@ +import type { AgentConfigFile } from "@/lib/types"; + +export interface MemoryGroup { + /** Storage directory (…/projects//memory), raw and never decoded. + * Doubles as the stable identity (React key / collapse-state key) and the + * header title. */ + storePath: string; + /** Project name when project-scoped, else null (Global). */ + projectName: string | null; + files: AgentConfigFile[]; + totalBytes: number; +} + +/** The directory that physically holds a memory file (path minus file name). */ +function storeDir(file: AgentConfigFile): string { + // Separator-agnostic: backend paths are `to_string_lossy()` output, which + // uses backslashes on Windows. Mirror the separator-safe logic used elsewhere. + const idx = Math.max(file.path.lastIndexOf("/"), file.path.lastIndexOf("\\")); + return idx >= 0 ? file.path.slice(0, idx) : file.path; +} + +/** + * Group memory files by storage directory. Project groups sort first (by name), + * then Global groups (by store path); files within a group sort by name. All + * files in one store share one owning project, so the first file's scope sets + * the group's `projectName`. + */ +export function groupMemoryFiles(files: AgentConfigFile[]): MemoryGroup[] { + const byDir = new Map(); + for (const file of files) { + const key = storeDir(file); + let group = byDir.get(key); + if (!group) { + group = { + storePath: key, + projectName: file.scope.type === "project" ? file.scope.name : null, + files: [], + totalBytes: 0, + }; + byDir.set(key, group); + } + group.files.push(file); + group.totalBytes += file.size_bytes; + } + const groups = [...byDir.values()]; + for (const g of groups) { + g.files.sort((a, b) => a.file_name.localeCompare(b.file_name)); + } + return groups.sort((a, b) => { + const aProj = a.projectName != null; + const bProj = b.projectName != null; + if (aProj !== bProj) return aProj ? -1 : 1; + const byLabel = (a.projectName ?? a.storePath).localeCompare( + b.projectName ?? b.storePath, + ); + return byLabel !== 0 ? byLabel : a.storePath.localeCompare(b.storePath); + }); +} diff --git a/src/hooks/__tests__/use-collapsible-state.test.ts b/src/hooks/__tests__/use-collapsible-state.test.ts new file mode 100644 index 00000000..bf83a1df --- /dev/null +++ b/src/hooks/__tests__/use-collapsible-state.test.ts @@ -0,0 +1,30 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useCollapsibleState } from "../use-collapsible-state"; + +beforeEach(() => localStorage.clear()); + +describe("useCollapsibleState", () => { + it("defaults to expanded and toggles + persists", () => { + const { result } = renderHook(() => useCollapsibleState("k")); + expect(result.current.collapsed).toBe(false); + act(() => result.current.toggle()); + expect(result.current.collapsed).toBe(true); + expect(localStorage.getItem("k")).toBe("1"); + act(() => result.current.setCollapsed(false)); + expect(localStorage.getItem("k")).toBeNull(); + }); + + it("rehydrates collapsed=true from storage", () => { + localStorage.setItem("k2", "1"); + const { result } = renderHook(() => useCollapsibleState("k2")); + expect(result.current.collapsed).toBe(true); + }); + + it("null key is session-only (no throw, no persistence)", () => { + const { result } = renderHook(() => useCollapsibleState(null)); + act(() => result.current.toggle()); + expect(result.current.collapsed).toBe(true); + expect(localStorage.length).toBe(0); + }); +}); diff --git a/src/hooks/use-collapsible-state.ts b/src/hooks/use-collapsible-state.ts new file mode 100644 index 00000000..259e0263 --- /dev/null +++ b/src/hooks/use-collapsible-state.ts @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useState } from "react"; + +/** Collapsed boolean persisted in localStorage under `storageKey`. Pass `null` + * to keep it session-only. Rehydrates when the key changes. + * + * `setCollapsed` is referentially stable (memoized on the key) and safe to use + * in effect deps. `toggle` is NOT stable — its identity changes whenever + * `collapsed` changes — so use it as an event handler, not in effect deps. */ +export function useCollapsibleState(storageKey: string | null): { + collapsed: boolean; + setCollapsed: (value: boolean) => void; + toggle: () => void; +} { + const [collapsed, setCollapsedState] = useState(() => + storageKey ? localStorage.getItem(storageKey) === "1" : false, + ); + + useEffect(() => { + if (!storageKey) return; + setCollapsedState(localStorage.getItem(storageKey) === "1"); + }, [storageKey]); + + const setCollapsed = useCallback( + (value: boolean) => { + setCollapsedState(value); + if (storageKey) { + if (value) localStorage.setItem(storageKey, "1"); + else localStorage.removeItem(storageKey); + } + }, + [storageKey], + ); + + const toggle = useCallback( + () => setCollapsed(!collapsed), + [setCollapsed, collapsed], + ); + + return { collapsed, setCollapsed, toggle }; +} diff --git a/src/lib/__tests__/format.test.ts b/src/lib/__tests__/format.test.ts new file mode 100644 index 00000000..21d658b6 --- /dev/null +++ b/src/lib/__tests__/format.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { formatBytes } from "../format"; + +describe("formatBytes", () => { + it("uses B under 1KB", () => expect(formatBytes(512)).toBe("512 B")); + it("uses one-decimal KB at/above 1KB", () => + expect(formatBytes(1536)).toBe("1.5 KB")); + it("handles zero", () => expect(formatBytes(0)).toBe("0 B")); +}); diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 00000000..a9278b82 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,4 @@ +/** Human byte label: `B` under 1KB, one-decimal `KB` at/above. */ +export function formatBytes(bytes: number): string { + return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`; +} diff --git a/src/lib/i18n/locales/en/agents.json b/src/lib/i18n/locales/en/agents.json index b3a62802..6de00a68 100644 --- a/src/lib/i18n/locales/en/agents.json +++ b/src/lib/i18n/locales/en/agents.json @@ -61,5 +61,9 @@ "rail": { "extensions": "Extensions", "jumpTo": "Jump to {{label}}" + }, + "memory": { + "fileCount_one": "{{count}} file", + "fileCount_other": "{{count}} files" } } diff --git a/src/lib/i18n/locales/zh/agents.json b/src/lib/i18n/locales/zh/agents.json index 2cfcde51..92c03f14 100644 --- a/src/lib/i18n/locales/zh/agents.json +++ b/src/lib/i18n/locales/zh/agents.json @@ -61,5 +61,9 @@ "rail": { "extensions": "扩展", "jumpTo": "跳转至 {{label}}" + }, + "memory": { + "fileCount_one": "{{count}} 个文件", + "fileCount_other": "{{count}} 个文件" } }