From 19462278e1be151803570311f1c26ef165177307 Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 2 Jul 2026 13:53:07 +0300 Subject: [PATCH 01/13] feat(adapter): add external_project_memory trait hook --- crates/hk-core/src/adapter/mod.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index 88e8f4a..0f5cc6f 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![] From 13183ba1d2844f7ef48d309ff2bf47f9b21b2c97 Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 2 Jul 2026 13:57:10 +0300 Subject: [PATCH 02/13] feat(claude): expose per-project memory via external_project_memory (cwd from transcript) --- crates/hk-core/src/adapter/claude.rs | 94 ++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 11 deletions(-) diff --git a/crates/hk-core/src/adapter/claude.rs b/crates/hk-core/src/adapter/claude.rs index 28bc08b..63f3df9 100644 --- a/crates/hk-core/src/adapter/claude.rs +++ b/crates/hk-core/src/adapter/claude.rs @@ -41,6 +41,33 @@ 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 Ok(v) = serde_json::from_str::(&line) { + if let Some(cwd) = v.get("cwd").and_then(|c| c.as_str()) { + return Some(PathBuf::from(cwd)); + } + } + } + } + } + None + } } impl AgentAdapter for ClaudeAdapter { @@ -181,20 +208,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 +416,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(); From 8481e9a78844b2d1e3642a2de6efb26d9354afd0 Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 2 Jul 2026 14:01:54 +0300 Subject: [PATCH 03/13] feat(scanner): classify external project memory into Project/Global scope --- crates/hk-core/src/scanner.rs | 68 +++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/hk-core/src/scanner.rs b/crates/hk-core/src/scanner.rs index 99c5869..6e24259 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(); From 5512701a474686416beffe18bc47f24588fb7832 Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 2 Jul 2026 14:10:01 +0300 Subject: [PATCH 04/13] refactor(claude): flatten session_cwd parse and harden transcript line reads --- crates/hk-core/src/adapter/claude.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/hk-core/src/adapter/claude.rs b/crates/hk-core/src/adapter/claude.rs index 63f3df9..bfee325 100644 --- a/crates/hk-core/src/adapter/claude.rs +++ b/crates/hk-core/src/adapter/claude.rs @@ -58,10 +58,11 @@ impl ClaudeAdapter { continue; }; for line in std::io::BufReader::new(file).lines().map_while(Result::ok) { - if let Ok(v) = serde_json::from_str::(&line) { - if let Some(cwd) = v.get("cwd").and_then(|c| c.as_str()) { - return Some(PathBuf::from(cwd)); - } + 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); } } } From 9ac21011b8c11e4d9f956bbe6ddd542f4fecf6f3 Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 2 Jul 2026 20:36:24 +0300 Subject: [PATCH 05/13] refactor(agents): extract formatBytes and add hideScopeMeta to ConfigFileEntry --- src/components/agents/config-file-entry.tsx | 44 +++++++++++++-------- src/lib/__tests__/format.test.ts | 9 +++++ src/lib/format.ts | 4 ++ 3 files changed, 40 insertions(+), 17 deletions(-) create mode 100644 src/lib/__tests__/format.test.ts create mode 100644 src/lib/format.ts diff --git a/src/components/agents/config-file-entry.tsx b/src/components/agents/config-file-entry.tsx index 88e6593..57caa1e 100644 --- a/src/components/agents/config-file-entry.tsx +++ b/src/components/agents/config-file-entry.tsx @@ -15,11 +15,20 @@ 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, + hideScopeMeta = false, +}: { + file: AgentConfigFile; + /** When true, suppress the per-row scope badge + scope path (shown once in a + * group header instead). Used by the grouped MEMORY view. */ + hideScopeMeta?: boolean; +}) { const { t } = useTranslation("agents"); const { t: tc } = useTranslation("common"); const expandedFiles = useAgentConfigStore((s) => s.expandedFiles); @@ -95,10 +104,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 (
@@ -133,19 +139,23 @@ export function ConfigFileEntry({ file }: { file: AgentConfigFile }) { {t("file.missing")} )} - {file.custom_id == null && - (file.scope.type === "global" ? ( - - {tc("scope.global")} + {!hideScopeMeta && ( + <> + {file.custom_id == null && + (file.scope.type === "global" ? ( + + {tc("scope.global")} + + ) : ( + + {tc("scope.project")} + + ))} + + {scopePath} - ) : ( - - {tc("scope.project")} - - ))} - - {scopePath} - + + )}
{!file.is_dir && ( diff --git a/src/lib/__tests__/format.test.ts b/src/lib/__tests__/format.test.ts new file mode 100644 index 0000000..21d658b --- /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 0000000..a9278b8 --- /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`; +} From fed2de0b9b1eddeff4c598c2238e3d7cc867fa13 Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 2 Jul 2026 20:39:39 +0300 Subject: [PATCH 06/13] refactor(agents): extract useCollapsibleState and adopt it in ConfigSection --- src/components/agents/config-section.tsx | 37 ++++------------- .../__tests__/use-collapsible-state.test.ts | 30 ++++++++++++++ src/hooks/use-collapsible-state.ts | 40 +++++++++++++++++++ 3 files changed, 78 insertions(+), 29 deletions(-) create mode 100644 src/hooks/__tests__/use-collapsible-state.test.ts create mode 100644 src/hooks/use-collapsible-state.ts diff --git a/src/components/agents/config-section.tsx b/src/components/agents/config-section.tsx index 4cac48d..db43ee1 100644 --- a/src/components/agents/config-section.tsx +++ b/src/components/agents/config-section.tsx @@ -9,8 +9,9 @@ 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"; @@ -45,40 +46,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; 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 0000000..bf83a1d --- /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 0000000..259e026 --- /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 }; +} From a00601d1e9b74c64bb5cbccc0ebe94df65e44442 Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 2 Jul 2026 20:44:57 +0300 Subject: [PATCH 07/13] feat(agents): add memory-file grouping helper --- .../agents/__tests__/memory-grouping.test.ts | 54 ++++++++++++++++++ src/components/agents/memory-grouping.ts | 56 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 src/components/agents/__tests__/memory-grouping.test.ts create mode 100644 src/components/agents/memory-grouping.ts 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 0000000..e6f52ff --- /dev/null +++ b/src/components/agents/__tests__/memory-grouping.test.ts @@ -0,0 +1,54 @@ +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(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("returns [] for empty input", () => { + expect(groupMemoryFiles([])).toEqual([]); + }); +}); diff --git a/src/components/agents/memory-grouping.ts b/src/components/agents/memory-grouping.ts new file mode 100644 index 0000000..026d533 --- /dev/null +++ b/src/components/agents/memory-grouping.ts @@ -0,0 +1,56 @@ +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 { + const idx = 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); + }); +} From 099ae8bb215911c9fcdf546fdcf01164a8faf212 Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 2 Jul 2026 20:52:15 +0300 Subject: [PATCH 08/13] feat(agents): group MEMORY section by storage dir with collapsible headers --- .../__tests__/config-section-memory.test.tsx | 47 ++++++++++++++ src/components/agents/config-section.tsx | 16 ++++- src/components/agents/memory-group.tsx | 65 +++++++++++++++++++ src/lib/i18n/locales/en/agents.json | 4 ++ src/lib/i18n/locales/zh/agents.json | 4 ++ 5 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 src/components/agents/__tests__/config-section-memory.test.tsx create mode 100644 src/components/agents/memory-group.tsx 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 0000000..31e254e --- /dev/null +++ b/src/components/agents/__tests__/config-section-memory.test.tsx @@ -0,0 +1,47 @@ +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(); + 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(); + }); +}); diff --git a/src/components/agents/config-section.tsx b/src/components/agents/config-section.tsx index db43ee1..c3e98d2 100644 --- a/src/components/agents/config-section.tsx +++ b/src/components/agents/config-section.tsx @@ -15,6 +15,8 @@ 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, @@ -83,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 0000000..24f0e19 --- /dev/null +++ b/src/components/agents/memory-group.tsx @@ -0,0 +1,65 @@ +import { ChevronDown, ChevronRight } 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 { t: tc } = useTranslation("common"); + 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/lib/i18n/locales/en/agents.json b/src/lib/i18n/locales/en/agents.json index b3a6280..6de00a6 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 2cfcde5..92c03f1 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}} 个文件" } } From da7b28f05a4762280dbb40e46c3bfaaef4b4b31f Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 3 Jul 2026 12:46:26 +0300 Subject: [PATCH 09/13] feat(agents): distinct memory group headers and per-row scope badge --- .../__tests__/config-section-memory.test.tsx | 3 ++ src/components/agents/config-file-entry.tsx | 35 +++++++++---------- src/components/agents/memory-group.tsx | 28 ++++++--------- 3 files changed, 30 insertions(+), 36 deletions(-) diff --git a/src/components/agents/__tests__/config-section-memory.test.tsx b/src/components/agents/__tests__/config-section-memory.test.tsx index 31e254e..27de594 100644 --- a/src/components/agents/__tests__/config-section-memory.test.tsx +++ b/src/components/agents/__tests__/config-section-memory.test.tsx @@ -43,5 +43,8 @@ describe("ConfigSection memory grouping", () => { // 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/config-file-entry.tsx b/src/components/agents/config-file-entry.tsx index 57caa1e..0e1b77a 100644 --- a/src/components/agents/config-file-entry.tsx +++ b/src/components/agents/config-file-entry.tsx @@ -22,12 +22,11 @@ import { useAgentConfigStore } from "@/stores/agent-config-store"; export function ConfigFileEntry({ file, - hideScopeMeta = false, + hideScopePath = false, }: { file: AgentConfigFile; - /** When true, suppress the per-row scope badge + scope path (shown once in a - * group header instead). Used by the grouped MEMORY view. */ - hideScopeMeta?: boolean; + /** 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; }) { const { t } = useTranslation("agents"); const { t: tc } = useTranslation("common"); @@ -139,22 +138,20 @@ export function ConfigFileEntry({ {t("file.missing")}
)} - {!hideScopeMeta && ( - <> - {file.custom_id == null && - (file.scope.type === "global" ? ( - - {tc("scope.global")} - - ) : ( - - {tc("scope.project")} - - ))} - - {scopePath} + {file.custom_id == null && + (file.scope.type === "global" ? ( + + {tc("scope.global")} - + ) : ( + + {tc("scope.project")} + + ))} + {!hideScopePath && ( + + {scopePath} + )} {!file.is_dir && ( diff --git a/src/components/agents/memory-group.tsx b/src/components/agents/memory-group.tsx index 24f0e19..dd2dac0 100644 --- a/src/components/agents/memory-group.tsx +++ b/src/components/agents/memory-group.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ChevronRight } from "lucide-react"; +import { ChevronDown, ChevronRight, Folder } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useCollapsibleState } from "@/hooks/use-collapsible-state"; import { formatBytes } from "@/lib/format"; @@ -18,7 +18,6 @@ export function MemoryGroup({ agentName?: string; }) { const { t } = useTranslation("agents"); - const { t: tc } = useTranslation("common"); const storageKey = agentName ? groupCollapseKey(agentName, group.storePath) : null; @@ -33,33 +32,28 @@ export function MemoryGroup({ type="button" onClick={toggle} aria-expanded={!collapsed} - className="w-full flex items-center gap-2 px-3 py-2 border-b border-border/50 last:border-b-0 hover:bg-accent/20 transition-colors text-left" + className="w-full flex items-center gap-2 px-3 py-2 bg-muted/40 border-b border-border hover:bg-muted/60 transition-colors text-left" > + {isProject ? group.projectName : group.storePath} - - {isProject ? tc("scope.project") : tc("scope.global")} - {t("memory.fileCount", { count: group.files.length })} ·{" "} {formatBytes(group.totalBytes)} - {!collapsed && - group.files.map((file) => ( - - ))} + {!collapsed && ( +
+ {group.files.map((file) => ( + + ))} +
+ )} ); } From 9ff60ffb01b982c340a3a7f11e4971acdacb9967 Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 3 Jul 2026 14:54:36 +0300 Subject: [PATCH 10/13] feat(agents): show storage path on project memory group headers --- .../__tests__/config-section-memory.test.tsx | 4 +++ src/components/agents/memory-group.tsx | 29 +++++++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/components/agents/__tests__/config-section-memory.test.tsx b/src/components/agents/__tests__/config-section-memory.test.tsx index 27de594..58d2bf3 100644 --- a/src/components/agents/__tests__/config-section-memory.test.tsx +++ b/src/components/agents/__tests__/config-section-memory.test.tsx @@ -35,6 +35,10 @@ describe("ConfigSection memory grouping", () => { />, ); 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(); diff --git a/src/components/agents/memory-group.tsx b/src/components/agents/memory-group.tsx index dd2dac0..441e3b8 100644 --- a/src/components/agents/memory-group.tsx +++ b/src/components/agents/memory-group.tsx @@ -36,12 +36,29 @@ export function MemoryGroup({ > - - {isProject ? group.projectName : group.storePath} - + {isProject ? ( + <> + + {group.projectName} + + + {group.storePath} + + + ) : ( + + {group.storePath} + + )} {t("memory.fileCount", { count: group.files.length })} ·{" "} {formatBytes(group.totalBytes)} From 0f1fac00067d5154cb1a0d7c07b63164c681eb22 Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 3 Jul 2026 18:04:26 +0300 Subject: [PATCH 11/13] fix(agents): group memory by dir on Windows backslash paths --- .../agents/__tests__/memory-grouping.test.ts | 19 ++++++++++++++++++- src/components/agents/memory-grouping.ts | 4 +++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/components/agents/__tests__/memory-grouping.test.ts b/src/components/agents/__tests__/memory-grouping.test.ts index e6f52ff..df6cc9e 100644 --- a/src/components/agents/__tests__/memory-grouping.test.ts +++ b/src/components/agents/__tests__/memory-grouping.test.ts @@ -12,7 +12,9 @@ function file( agent: "claude", category: "memory", scope, - file_name: path.slice(path.lastIndexOf("/") + 1), + file_name: path.slice( + Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1, + ), size_bytes: size, modified_at: null, is_dir: false, @@ -48,6 +50,21 @@ describe("groupMemoryFiles", () => { 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/memory-grouping.ts b/src/components/agents/memory-grouping.ts index 026d533..933097a 100644 --- a/src/components/agents/memory-grouping.ts +++ b/src/components/agents/memory-grouping.ts @@ -13,7 +13,9 @@ export interface MemoryGroup { /** The directory that physically holds a memory file (path minus file name). */ function storeDir(file: AgentConfigFile): string { - const idx = file.path.lastIndexOf("/"); + // 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; } From a54ad4cb39fcc83d60fdae2566fd5faee835dbc3 Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 3 Jul 2026 18:06:31 +0300 Subject: [PATCH 12/13] test(adapter): exercise external_project_memory in default-methods smoke test --- crates/hk-core/src/adapter/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index 0f5cc6f..0ccff4e 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -519,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(); From 2fc5cf986d41d4ece2d55969e0262b417dc21491 Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 3 Jul 2026 21:59:34 +0300 Subject: [PATCH 13/13] fix(agents): memory row hover highlight fills to the left edge --- src/components/agents/config-file-entry.tsx | 8 +++++++- src/components/agents/memory-group.tsx | 11 ++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/agents/config-file-entry.tsx b/src/components/agents/config-file-entry.tsx index 0e1b77a..612da45 100644 --- a/src/components/agents/config-file-entry.tsx +++ b/src/components/agents/config-file-entry.tsx @@ -23,10 +23,15 @@ import { useAgentConfigStore } from "@/stores/agent-config-store"; 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"); @@ -111,7 +116,8 @@ export function ConfigFileEntry({ 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", diff --git a/src/components/agents/memory-group.tsx b/src/components/agents/memory-group.tsx index 441e3b8..09d4322 100644 --- a/src/components/agents/memory-group.tsx +++ b/src/components/agents/memory-group.tsx @@ -64,13 +64,10 @@ export function MemoryGroup({ {formatBytes(group.totalBytes)} - {!collapsed && ( -
- {group.files.map((file) => ( - - ))} -
- )} + {!collapsed && + group.files.map((file) => ( + + ))} ); }