sync: update from private repo (a67c053)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-17 01:04:08 +00:00
parent cbdfdeaa18
commit 138401ce1b
9 changed files with 489 additions and 49 deletions
+148 -47
View File
@@ -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 && (
+5
View File
@@ -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}})",
+5
View File
@@ -163,6 +163,11 @@
"entriesTitle": "メモリエントリ",
"entriesSubtitle": "エージェントの毎セッションに注入される永続的な情報。",
"newEntry": "+ 新しいエントリ",
"filterAll": "すべて",
"searchPlaceholder": "メモリを検索(名前・説明・本文)",
"noMatches": "条件に一致するメモリはありません。",
"expand": "全文を表示",
"collapse": "折りたたむ",
"loading": "読み込み中…",
"loadFailed": "メモリエントリの読み込みに失敗しました: {{error}}",
"fetchFailed": "メモリエントリの読み込みに失敗しました ({{status}})",
+48
View File
@@ -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);
});
});
+45
View File
@@ -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)
);
});
}