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 && (
|
||||
|
||||
Reference in New Issue
Block a user