This commit is contained in:
@@ -6,15 +6,29 @@
|
||||
* なので User Folder へ移設(Settings 側は Reflection タイムラインのみ)。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { HelpText } from '../settings/HelpText';
|
||||
import { summarizeMemory, filterMemory, MEMORY_TYPES, type MemoryType } from '../../lib/memorySummary';
|
||||
|
||||
// ── Per-type accent classes (literal strings so Tailwind keeps them) ───────────
|
||||
|
||||
const TYPE_BADGE: Record<MemoryType, string> = {
|
||||
user: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30',
|
||||
feedback: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30',
|
||||
project: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30',
|
||||
reference: 'bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30',
|
||||
};
|
||||
const TYPE_DOT: Record<MemoryType, string> = {
|
||||
user: 'bg-blue-500',
|
||||
feedback: 'bg-amber-500',
|
||||
project: 'bg-emerald-500',
|
||||
reference: 'bg-violet-500',
|
||||
};
|
||||
|
||||
// ── API types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type MemoryType = 'user' | 'feedback' | 'project' | 'reference';
|
||||
|
||||
// Mirrors the server's flat shape from `listMemoryEntries` in
|
||||
// src/user-folder/memory.ts and `GET /api/local/memory/entries` in
|
||||
// src/bridge/memory-api.ts. If you change this shape, update both.
|
||||
@@ -88,8 +102,6 @@ interface EntryFormState {
|
||||
body: string;
|
||||
}
|
||||
|
||||
const MEMORY_TYPES: MemoryType[] = ['user', 'feedback', 'project', 'reference'];
|
||||
|
||||
function MemoryEntryModal({
|
||||
initial,
|
||||
isNew,
|
||||
@@ -129,7 +141,7 @@ function MemoryEntryModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-full max-w-lg mx-4 flex flex-col max-h-[90vh]">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-full max-w-4xl mx-4 flex flex-col h-[88vh] max-h-[88vh]">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-hairline">
|
||||
<h3 className="text-sm font-semibold text-slate-800">
|
||||
{isNew ? t('memory.modal.newTitle') : t('memory.modal.editTitle', { name: initial.name })}
|
||||
@@ -143,7 +155,7 @@ function MemoryEntryModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 space-y-3 flex-1">
|
||||
<div className="overflow-y-auto p-4 space-y-3 flex-1 flex flex-col min-h-0">
|
||||
{/* Name — only editable when creating */}
|
||||
<div>
|
||||
<label className="block text-2xs font-medium text-slate-600 mb-1">
|
||||
@@ -189,13 +201,12 @@ function MemoryEntryModal({
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<label className="block text-2xs font-medium text-slate-600 mb-1">{t('memory.modal.body')}</label>
|
||||
<textarea
|
||||
value={form.body}
|
||||
onChange={e => set('body', e.target.value)}
|
||||
rows={8}
|
||||
className="w-full px-2.5 py-2 text-xs font-mono border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none resize-y"
|
||||
className="w-full flex-1 min-h-[240px] px-2.5 py-2 text-xs font-mono border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none resize-y"
|
||||
placeholder={t('memory.modal.bodyPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
@@ -249,6 +260,24 @@ function MemoryEntriesPanel() {
|
||||
const [modal, setModal] = useState<{ entry: EntryFormState; isNew: boolean } | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<MemoryType | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const allEntries = data?.entries ?? [];
|
||||
const summary = useMemo(() => summarizeMemory(allEntries), [allEntries]);
|
||||
const filtered = useMemo(
|
||||
() => filterMemory(allEntries, { query, type: typeFilter }),
|
||||
[allEntries, query, typeFilter],
|
||||
);
|
||||
|
||||
const toggleExpand = (name: string) =>
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
|
||||
const handleNew = () => {
|
||||
setModal({
|
||||
@@ -330,43 +359,115 @@ function MemoryEntriesPanel() {
|
||||
)}
|
||||
|
||||
{data && data.entries.length > 0 && (
|
||||
<ul className="divide-y divide-hairline">
|
||||
{data.entries.map(entry => (
|
||||
<li key={entry.name} className="flex items-start gap-3 px-4 py-3 hover:bg-surface/60 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono font-medium text-slate-800 truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-500 flex-shrink-0">
|
||||
{entry.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-2xs text-slate-500 mt-0.5 truncate">{entry.description}</p>
|
||||
{entry.body && (
|
||||
<p className="text-2xs text-slate-400 mt-0.5 line-clamp-2 font-mono whitespace-pre-wrap break-words">
|
||||
{entry.body.slice(0, 200)}{entry.body.length > 200 ? '…' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-shrink-0 mt-0.5">
|
||||
<button
|
||||
onClick={() => handleEdit(entry)}
|
||||
className="px-2 h-6 text-2xs text-slate-600 border border-hairline bg-canvas hover:bg-surface rounded transition-colors"
|
||||
>
|
||||
{t('memory.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDelete(entry.name)}
|
||||
disabled={deleting === entry.name}
|
||||
className="px-2 h-6 text-2xs text-red-700 dark:text-red-300 border border-red-200 bg-canvas hover:bg-red-50 dark:hover:bg-red-500/15 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deleting === entry.name ? '…' : t('memory.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
{/* Summary + filters: total count and a clickable chip per type so you
|
||||
can see at a glance how much of each kind is stored, and filter. */}
|
||||
<div className="px-4 py-3 border-b border-hairline bg-surface/40 space-y-2.5">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setTypeFilter(null)}
|
||||
aria-pressed={typeFilter === null}
|
||||
className={`inline-flex items-center gap-1.5 px-2 h-7 rounded-md text-2xs font-medium border transition-colors ${
|
||||
typeFilter === null
|
||||
? 'border-accent bg-accent/10 text-slate-900 font-semibold'
|
||||
: 'border-hairline bg-canvas text-slate-600 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
{t('memory.filterAll')}
|
||||
<span className="tabular-nums text-slate-500">{summary.total}</span>
|
||||
</button>
|
||||
{MEMORY_TYPES.map(type => {
|
||||
const active = typeFilter === type;
|
||||
const count = summary.byType[type];
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setTypeFilter(active ? null : type)}
|
||||
aria-pressed={active}
|
||||
className={`inline-flex items-center gap-1.5 px-2 h-7 rounded-md text-2xs font-medium border transition-colors ${
|
||||
active ? TYPE_BADGE[type] + ' font-semibold' : 'border-hairline bg-canvas text-slate-600 hover:bg-surface'
|
||||
} ${count === 0 ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${TYPE_DOT[type]}`} aria-hidden="true" />
|
||||
{type}
|
||||
<span className="tabular-nums">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('memory.searchPlaceholder')}
|
||||
className="w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-2xs text-slate-400">{t('memory.noMatches')}</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-hairline">
|
||||
{filtered.map(entry => {
|
||||
const isExpanded = expanded.has(entry.name);
|
||||
return (
|
||||
<li key={entry.name} className="flex items-start gap-3 px-4 py-3 hover:bg-surface/60 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono font-medium text-slate-800 truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded border flex-shrink-0 ${TYPE_BADGE[entry.type]}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${TYPE_DOT[entry.type]}`} aria-hidden="true" />
|
||||
{entry.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">{entry.description}</p>
|
||||
{entry.body && (() => {
|
||||
// Only offer expand when the body is long enough to be clamped.
|
||||
const longBody = entry.body.length > 120 || entry.body.split('\n').length > 2;
|
||||
return (
|
||||
<>
|
||||
<pre className={`text-2xs text-slate-500 mt-1 font-mono whitespace-pre-wrap break-words ${
|
||||
isExpanded || !longBody
|
||||
? (longBody ? 'max-h-72 overflow-y-auto bg-surface/60 border border-hairline rounded p-2' : '')
|
||||
: 'line-clamp-2'
|
||||
}`}>
|
||||
{entry.body}
|
||||
</pre>
|
||||
{longBody && (
|
||||
<button
|
||||
onClick={() => toggleExpand(entry.name)}
|
||||
className="mt-1 text-[10px] text-accent hover:underline"
|
||||
>
|
||||
{isExpanded ? t('memory.collapse') : t('memory.expand')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-shrink-0 mt-0.5">
|
||||
<button
|
||||
onClick={() => handleEdit(entry)}
|
||||
className="px-2 h-6 text-2xs text-slate-600 border border-hairline bg-canvas hover:bg-surface rounded transition-colors"
|
||||
>
|
||||
{t('memory.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDelete(entry.name)}
|
||||
disabled={deleting === entry.name}
|
||||
className="px-2 h-6 text-2xs text-red-700 dark:text-red-300 border border-red-200 bg-canvas hover:bg-red-50 dark:hover:bg-red-500/15 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deleting === entry.name ? '…' : t('memory.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
|
||||
@@ -163,6 +163,11 @@
|
||||
"entriesTitle": "Memory entries",
|
||||
"entriesSubtitle": "Persistent information injected into every agent session.",
|
||||
"newEntry": "+ New entry",
|
||||
"filterAll": "All",
|
||||
"searchPlaceholder": "Search memory (name, description, body)",
|
||||
"noMatches": "No memory entries match your filter.",
|
||||
"expand": "Show full",
|
||||
"collapse": "Collapse",
|
||||
"loading": "Loading…",
|
||||
"loadFailed": "Failed to load memory entries: {{error}}",
|
||||
"fetchFailed": "Failed to load memory entries ({{status}})",
|
||||
|
||||
@@ -163,6 +163,11 @@
|
||||
"entriesTitle": "メモリエントリ",
|
||||
"entriesSubtitle": "エージェントの毎セッションに注入される永続的な情報。",
|
||||
"newEntry": "+ 新しいエントリ",
|
||||
"filterAll": "すべて",
|
||||
"searchPlaceholder": "メモリを検索(名前・説明・本文)",
|
||||
"noMatches": "条件に一致するメモリはありません。",
|
||||
"expand": "全文を表示",
|
||||
"collapse": "折りたたむ",
|
||||
"loading": "読み込み中…",
|
||||
"loadFailed": "メモリエントリの読み込みに失敗しました: {{error}}",
|
||||
"fetchFailed": "メモリエントリの読み込みに失敗しました ({{status}})",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { summarizeMemory, filterMemory, type MemoryEntryLike } from './memorySummary';
|
||||
|
||||
const E = (name: string, type: MemoryEntryLike['type'], description = '', body = ''): MemoryEntryLike =>
|
||||
({ name, type, description, body });
|
||||
|
||||
const entries: MemoryEntryLike[] = [
|
||||
E('a', 'user', 'who I am', 'role: admin'),
|
||||
E('b', 'feedback', 'no filler', 'avoid agreement'),
|
||||
E('c', 'feedback', 'investigate first', 'check config'),
|
||||
E('d', 'project', 'maestro rename', 'PR #385'),
|
||||
E('e', 'reference', 'gitea token', 'in ~/.gitea-token'),
|
||||
];
|
||||
|
||||
describe('summarizeMemory', () => {
|
||||
it('counts total and per type', () => {
|
||||
expect(summarizeMemory(entries)).toEqual({
|
||||
total: 5,
|
||||
byType: { user: 1, feedback: 2, project: 1, reference: 1 },
|
||||
});
|
||||
});
|
||||
it('returns zeroed counts for an empty list', () => {
|
||||
expect(summarizeMemory([])).toEqual({
|
||||
total: 0,
|
||||
byType: { user: 0, feedback: 0, project: 0, reference: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterMemory', () => {
|
||||
it('returns all when no query/type', () => {
|
||||
expect(filterMemory(entries, {})).toHaveLength(5);
|
||||
});
|
||||
it('filters by type', () => {
|
||||
expect(filterMemory(entries, { type: 'feedback' }).map(e => e.name)).toEqual(['b', 'c']);
|
||||
});
|
||||
it('searches name, description and body (case-insensitive)', () => {
|
||||
expect(filterMemory(entries, { query: 'ADMIN' }).map(e => e.name)).toEqual(['a']); // body
|
||||
expect(filterMemory(entries, { query: 'rename' }).map(e => e.name)).toEqual(['d']); // description
|
||||
expect(filterMemory(entries, { query: 'gitea' }).map(e => e.name)).toEqual(['e']); // name+body
|
||||
});
|
||||
it('combines type and query', () => {
|
||||
expect(filterMemory(entries, { type: 'feedback', query: 'config' }).map(e => e.name)).toEqual(['c']);
|
||||
});
|
||||
it('ignores a null type', () => {
|
||||
expect(filterMemory(entries, { type: null, query: '' })).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* memorySummary.ts — pure helpers for the memory panel: count entries per type
|
||||
* and filter by type / free-text. Kept separate from the component so it's
|
||||
* unit-testable.
|
||||
*/
|
||||
|
||||
export type MemoryType = 'user' | 'feedback' | 'project' | 'reference';
|
||||
|
||||
export const MEMORY_TYPES: MemoryType[] = ['user', 'feedback', 'project', 'reference'];
|
||||
|
||||
export interface MemoryEntryLike {
|
||||
name: string;
|
||||
description: string;
|
||||
type: MemoryType;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface MemorySummary {
|
||||
total: number;
|
||||
byType: Record<MemoryType, number>;
|
||||
}
|
||||
|
||||
export function summarizeMemory(entries: MemoryEntryLike[]): MemorySummary {
|
||||
const byType: Record<MemoryType, number> = { user: 0, feedback: 0, project: 0, reference: 0 };
|
||||
for (const e of entries) {
|
||||
if (e.type in byType) byType[e.type] += 1;
|
||||
}
|
||||
return { total: entries.length, byType };
|
||||
}
|
||||
|
||||
export function filterMemory(
|
||||
entries: MemoryEntryLike[],
|
||||
opts: { query?: string; type?: MemoryType | null },
|
||||
): MemoryEntryLike[] {
|
||||
const q = (opts.query ?? '').trim().toLowerCase();
|
||||
return entries.filter((e) => {
|
||||
if (opts.type && e.type !== opts.type) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
e.name.toLowerCase().includes(q) ||
|
||||
e.description.toLowerCase().includes(q) ||
|
||||
e.body.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user