Make memory activity visible in desktop workflows

Desktop users could not tell when project memory was being referenced or updated unless they opened raw tool details. This surfaces memory reads and writes as a dedicated chat activity, while keeping ordinary tool calls visible in mixed groups, and tightens the memory settings layout for faster project and file navigation.

Constraint: CLI memory currently arrives through system notifications and normal file tool calls rather than a dedicated memory tool.
Rejected: Hide memory file writes inside the existing file tool group | users need a distinct product signal for memory activity.
Rejected: Preserve the manual create-memory control | project memories are model-produced files and the user already asked to remove manual creation.
Confidence: high
Scope-risk: moderate
Directive: Keep non-memory tool calls on the normal rendering path when adding more memory activity signals.
Tested: cd desktop && bun run test -- MessageList memorySettings
Tested: bun run check:desktop
Tested: agent-browser screenshots for settings and chat memory activity under /tmp/cc-haha-memory-redesign-*.png
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 20:59:53 +08:00
parent ca5115efba
commit 7a2d0444ff
5 changed files with 496 additions and 53 deletions

View File

@ -174,6 +174,125 @@ describe('MessageList nested tool calls', () => {
expect(useTabStore.getState().activeTabId).toBe('__settings__')
})
it('promotes memory file writes from tool calls into a dedicated memory card', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'tool-write-memory',
type: 'tool_use',
toolName: 'Write',
toolUseId: 'write-memory',
input: {
file_path: '/Users/test/.claude/projects/example/memory/preferences.md',
content: '# Preferences\n',
},
timestamp: 1,
},
{
id: 'result-write-memory',
type: 'tool_result',
toolUseId: 'write-memory',
content: 'File written successfully',
isError: false,
timestamp: 2,
},
],
}),
},
})
render(<MessageList sessionId={ACTIVE_TAB} />)
expect(screen.getByText('Saved 1 memory item(s)')).toBeTruthy()
expect(screen.getByText('preferences.md')).toBeTruthy()
expect(screen.getByText('Tool details')).toBeTruthy()
})
it('promotes memory file reads into collapsible memory references', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'tool-read-memory-1',
type: 'tool_use',
toolName: 'Read',
toolUseId: 'read-memory-1',
input: { file_path: '/Users/test/.claude/projects/example/memory/MEMORY.md' },
timestamp: 1,
},
{
id: 'result-read-memory-1',
type: 'tool_result',
toolUseId: 'read-memory-1',
content: '1 # Project Memory\n2\n3 billing ledger rules',
isError: false,
timestamp: 2,
},
{
id: 'tool-read-memory-2',
type: 'tool_use',
toolName: 'Read',
toolUseId: 'read-memory-2',
input: { file_path: '/Users/test/.claude/projects/example/memory/workflow.md' },
timestamp: 3,
},
],
}),
},
})
render(<MessageList sessionId={ACTIVE_TAB} />)
expect(screen.getByText('2 memory reference(s)')).toBeTruthy()
fireEvent.click(screen.getByText('2 memory reference(s)'))
expect(screen.getByText('MEMORY.md')).toBeTruthy()
expect(screen.getByText('workflow.md')).toBeTruthy()
})
it('keeps non-memory tools visible when a tool group also touches memory files', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'tool-read-memory',
type: 'tool_use',
toolName: 'Read',
toolUseId: 'read-memory',
input: { file_path: '/Users/test/.claude/projects/example/memory/MEMORY.md' },
timestamp: 1,
},
{
id: 'tool-bash',
type: 'tool_use',
toolName: 'Bash',
toolUseId: 'bash-1',
input: { command: 'bun test' },
timestamp: 2,
},
{
id: 'result-bash',
type: 'tool_result',
toolUseId: 'bash-1',
content: 'ok',
isError: false,
timestamp: 3,
},
],
}),
},
})
render(<MessageList sessionId={ACTIVE_TAB} />)
expect(screen.getByText('1 memory reference(s)')).toBeTruthy()
expect(screen.getByText('Bash')).toBeTruthy()
expect(screen.getByText('bun test')).toBeTruthy()
})
it('keeps root tool runs split when nested child tool calls appear between them', () => {
const messages: UIMessage[] = [
{

View File

@ -1,14 +1,31 @@
import { useEffect, useState } from 'react'
import { BookMarked, ChevronDown, ChevronRight, Settings } from 'lucide-react'
import { ToolCallBlock } from './ToolCallBlock'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { Modal } from '../shared/Modal'
import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
import { AGENT_LIFECYCLE_TYPES } from '../../types/team'
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
type MemoryToolAction = 'saved' | 'referenced'
type MemoryToolFile = {
path: string
label: string
action: MemoryToolAction
lineHint?: string
preview?: string
}
type MemoryToolActivity = {
action: MemoryToolAction
files: MemoryToolFile[]
}
type Props = {
toolCalls: ToolCall[]
@ -59,6 +76,59 @@ export function ToolCallGroup({
childToolCallsByParent,
agentTaskNotifications,
isStreaming,
}: Props) {
const memoryActivity = getMemoryToolActivity(toolCalls, resultMap)
if (memoryActivity) {
const memoryToolCalls = toolCalls.filter(isMemoryToolCall)
const regularToolCalls = toolCalls.filter((toolCall) => !isMemoryToolCall(toolCall))
if (regularToolCalls.length > 0) {
return (
<div className="mb-2 space-y-2">
<MemoryToolActivityGroup
activity={memoryActivity}
toolCalls={memoryToolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
isStreaming={isStreaming}
/>
<ToolCallGroupContent
toolCalls={regularToolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
isStreaming={isStreaming}
/>
</div>
)
}
return (
<MemoryToolActivityGroup
activity={memoryActivity}
toolCalls={memoryToolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
isStreaming={isStreaming}
/>
)
}
return (
<ToolCallGroupContent
toolCalls={toolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
isStreaming={isStreaming}
/>
)
}
function ToolCallGroupContent({
toolCalls,
resultMap,
childToolCallsByParent,
agentTaskNotifications,
isStreaming,
}: Props) {
const allAgents = toolCalls.every((toolCall) => toolCall.toolName === 'Agent')
@ -97,6 +167,123 @@ export function ToolCallGroup({
)
}
function MemoryToolActivityGroup({
activity,
toolCalls,
resultMap,
childToolCallsByParent,
isStreaming,
}: {
activity: MemoryToolActivity
toolCalls: ToolCall[]
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
isStreaming?: boolean
}) {
const [expanded, setExpanded] = useState(activity.action === 'saved')
const [detailsExpanded, setDetailsExpanded] = useState(false)
const t = useTranslation()
const titleKey = activity.action === 'saved'
? 'chat.memorySavedFromToolsTitle'
: 'chat.memoryReferencedTitle'
const visibleFiles = activity.files.slice(0, 4)
const hiddenCount = Math.max(0, activity.files.length - visibleFiles.length)
useEffect(() => {
if (isStreaming) setExpanded(true)
}, [isStreaming])
return (
<div className="mb-2">
<div className="overflow-hidden rounded-lg border border-[color-mix(in_srgb,var(--color-brand)_22%,var(--color-border))] bg-[color-mix(in_srgb,var(--color-brand)_6%,var(--color-surface-container-lowest))]">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50"
>
{expanded ? (
<ChevronDown size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
) : (
<ChevronRight size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
)}
<BookMarked size={15} className="shrink-0 text-[var(--color-brand)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{t(titleKey, { count: activity.files.length })}
</span>
{isStreaming ? (
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-brand)] animate-pulse-dot" />
) : null}
</button>
{expanded ? (
<div className="border-t border-[var(--color-border)]/55 px-3 py-2.5">
<div className="space-y-1.5">
{visibleFiles.map((file) => (
<button
key={file.path}
type="button"
title={file.path}
onClick={() => openMemorySettings(file.path)}
className="group flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-tertiary)] group-hover:text-[var(--color-brand)]">
<Settings size={12} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{file.label}
</span>
{file.lineHint ? (
<span className="shrink-0 text-[12px] text-[var(--color-text-tertiary)]">
{file.lineHint}
</span>
) : null}
</span>
{file.preview ? (
<span className="mt-0.5 line-clamp-2 text-[12px] leading-5 text-[var(--color-text-secondary)]">
{file.preview}
</span>
) : null}
</span>
</button>
))}
{hiddenCount > 0 ? (
<div className="px-2 py-1 text-[12px] text-[var(--color-text-tertiary)]">
{t('chat.memoryMoreFiles', { count: hiddenCount })}
</div>
) : null}
</div>
<button
type="button"
onClick={() => setDetailsExpanded((value) => !value)}
className="mt-2 inline-flex h-7 items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
{detailsExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{t('chat.memoryTechnicalDetails')}
</button>
{detailsExpanded ? (
<div className="mt-2 space-y-1">
{toolCalls.map((toolCall) => (
<ToolCallTree
key={toolCall.id}
toolCall={toolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
/>
))}
</div>
) : null}
</div>
) : null}
</div>
</div>
)
}
function AgentToolGroup({
toolCalls,
resultMap,
@ -442,6 +629,108 @@ function ToolCallTree({
)
}
function openMemorySettings(path?: string) {
const ui = useUIStore.getState()
if (path) ui.setPendingMemoryPath(path)
ui.setPendingSettingsTab('memory')
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
}
function getMemoryToolActivity(
toolCalls: ToolCall[],
resultMap: Map<string, ToolResult>,
): MemoryToolActivity | null {
const filesByPath = new Map<string, MemoryToolFile>()
let sawSave = false
for (const toolCall of toolCalls) {
const path = getToolFilePath(toolCall.input)
if (!path || !isMemoryMarkdownPath(path)) continue
const isSave = isMemoryWriteTool(toolCall.toolName)
const isReference = toolCall.toolName === 'Read'
if (!isSave && !isReference) continue
sawSave ||= isSave
const result = resultMap.get(toolCall.toolUseId)
const preview = extractMemoryPreview(result?.content)
const current = filesByPath.get(path)
filesByPath.set(path, {
path,
label: memoryFileLabel(path),
action: isSave ? 'saved' : (current?.action ?? 'referenced'),
lineHint: preview.lineHint || current?.lineHint,
preview: preview.text || current?.preview,
})
}
if (filesByPath.size === 0) return null
return {
action: sawSave ? 'saved' : 'referenced',
files: [...filesByPath.values()],
}
}
function isMemoryToolCall(toolCall: ToolCall): boolean {
const path = getToolFilePath(toolCall.input)
if (!path || !isMemoryMarkdownPath(path)) return false
return toolCall.toolName === 'Read' || isMemoryWriteTool(toolCall.toolName)
}
function isMemoryWriteTool(toolName: string): boolean {
return toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit'
}
function getToolFilePath(input: unknown): string | null {
if (!input || typeof input !== 'object') return null
const record = input as Record<string, unknown>
const filePath = record.file_path ?? record.path
return typeof filePath === 'string' ? filePath : null
}
function isMemoryMarkdownPath(path: string): boolean {
const normalized = path.replace(/\\/g, '/')
return normalized.endsWith('.md') && normalized.includes('/memory/')
}
function memoryFileLabel(path: string): string {
const normalized = path.replace(/\\/g, '/')
return normalized.split('/').pop() || normalized
}
function extractMemoryPreview(content: unknown): { text?: string; lineHint?: string } {
const raw = extractTextContent(content)
if (!raw) return {}
const lineHint = extractLineHint(raw)
const lines = raw
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
.split(/\r?\n/)
.map((line) => line.replace(/^\s*\d+\s*/, '').trim())
.filter(Boolean)
let inFrontmatter = false
for (const line of lines) {
if (line === '---') {
inFrontmatter = !inFrontmatter
continue
}
if (inFrontmatter) continue
const normalized = line.replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim()
if (!normalized || normalized === '---') continue
if (/^(file|lines?|total)\b/i.test(normalized)) continue
return {
text: normalized.length > 140 ? `${normalized.slice(0, 140)}...` : normalized,
lineHint,
}
}
return { lineHint }
}
function extractLineHint(text: string): string | undefined {
const match = text.match(/(\d+)\s+lines?\b/i) ?? text.match(/(\d+)\s+行/)
return match?.[1] ? `${match[1]} lines` : undefined
}
type AgentStatus = 'starting' | 'running' | 'done' | 'failed' | 'stopped'
type AgentTaskStatus = AgentTaskNotification['status']

View File

@ -555,6 +555,8 @@ export const en = {
'settings.memory.selectProject': 'Select a project.',
'settings.memory.noFileSelected': 'No file selected',
'settings.memory.current': 'Current',
'settings.memory.indexFile': 'Index',
'settings.memory.missing': 'Missing',
'settings.memory.fileCount': '{count} files',
'settings.memory.unsaved': 'Unsaved',
'settings.memory.saved': 'Saved',
@ -943,8 +945,11 @@ export const en = {
'chat.stopTitle': 'Stop generation (Cmd+.)',
'chat.jumpToLatest': 'Latest',
'chat.memorySavedTitle': 'Saved {count} memory file(s)',
'chat.memorySavedFromToolsTitle': 'Saved {count} memory item(s)',
'chat.memoryReferencedTitle': '{count} memory reference(s)',
'chat.memoryOpenSettings': 'Open Memory',
'chat.memoryMoreFiles': '+{count} more',
'chat.memoryTechnicalDetails': 'Tool details',
'chat.rewindSuccessWithCode': 'Rewound {count} messages and restored tracked files.',
'chat.rewindSuccessConversationOnly': 'Rewound {count} messages. No file checkpoint was available for this turn.',
'chat.turnChangesTitle': '{count} files changed',

View File

@ -557,6 +557,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.memory.selectProject': '选择一个项目。',
'settings.memory.noFileSelected': '未选择文件',
'settings.memory.current': '当前',
'settings.memory.indexFile': '索引',
'settings.memory.missing': '目录缺失',
'settings.memory.fileCount': '{count} 个文件',
'settings.memory.unsaved': '未保存',
'settings.memory.saved': '已保存',
@ -945,8 +947,11 @@ export const zh: Record<TranslationKey, string> = {
'chat.stopTitle': '停止生成 (Cmd+.)',
'chat.jumpToLatest': '回到最新',
'chat.memorySavedTitle': '已保存 {count} 个记忆文件',
'chat.memorySavedFromToolsTitle': '保存了 {count} 条记忆',
'chat.memoryReferencedTitle': '{count} 条记忆引用',
'chat.memoryOpenSettings': '打开记忆',
'chat.memoryMoreFiles': '还有 {count} 个',
'chat.memoryTechnicalDetails': '工具详情',
'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。',
'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。',
'chat.turnChangesTitle': '{count} 个文件已更改',

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { FileText, Search, X } from 'lucide-react'
import type { ReactNode } from 'react'
import { BookOpenText, Database, FileText, FolderGit2, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react'
import { Button } from '../components/shared/Button'
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
import { useTranslation } from '../i18n'
@ -128,15 +129,21 @@ export function MemorySettings() {
}
return (
<div className="flex h-full min-h-[640px] flex-col gap-5">
<header className="flex flex-col gap-4 border-b border-[var(--color-border)] pb-5 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
{t('settings.memory.title')}
</h2>
<p className="mt-1 max-w-3xl text-sm leading-6 text-[var(--color-text-secondary)]">
{t('settings.memory.description')}
</p>
<div className="flex h-full min-h-[640px] flex-col gap-4">
<header className="grid gap-4 border-b border-[var(--color-border)] pb-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="grid min-w-0 gap-2">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-brand)]">
<BookOpenText size={16} aria-hidden="true" />
</span>
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
{t('settings.memory.title')}
</h2>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 pl-10 text-xs text-[var(--color-text-tertiary)]">
<span className="truncate font-mono">{activeCwd ? projectDisplayName(activeCwd) : '~/.claude/projects'}</span>
<span>{t('settings.memory.fileCount', { count: files.length })}</span>
</div>
</div>
<Button
type="button"
@ -144,7 +151,7 @@ export function MemorySettings() {
size="sm"
onClick={handleRefresh}
loading={isLoadingProjects || isLoadingFiles}
icon={<span className="material-symbols-outlined text-[16px]">refresh</span>}
icon={<RefreshCw size={15} aria-hidden="true" />}
>
{t('settings.memory.refresh')}
</Button>
@ -156,14 +163,15 @@ export function MemorySettings() {
</div>
)}
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="grid min-h-0 content-start gap-4">
<section className="min-h-0 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[390px_minmax(0,1fr)]">
<aside className="grid min-h-0 grid-rows-[minmax(210px,0.9fr)_minmax(260px,1.1fr)] overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<section className="flex min-h-0 flex-col">
<PanelHeader
icon={<FolderGit2 size={15} aria-hidden="true" />}
title={t('settings.memory.projects')}
meta={isLoadingProjects ? t('common.loading') : String(projects.length)}
/>
<div className="border-b border-[var(--color-border)] p-3">
<div className="px-3 py-3">
<SearchField
value={projectQuery}
onChange={setProjectQuery}
@ -172,11 +180,11 @@ export function MemorySettings() {
clearLabel={t('settings.memory.clearSearch')}
/>
</div>
<div className="max-h-[320px] overflow-y-auto p-2">
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{projects.length === 0 && !isLoadingProjects ? (
<EmptyState text={t('settings.memory.emptyProjects')} />
<EmptyState icon={<FolderGit2 size={18} />} text={t('settings.memory.emptyProjects')} />
) : filteredProjects.length === 0 ? (
<EmptyState text={t('settings.memory.noProjectMatches')} />
<EmptyState icon={<Search size={18} />} text={t('settings.memory.noProjectMatches')} />
) : (
filteredProjects.map((project) => (
<ProjectRow
@ -190,27 +198,26 @@ export function MemorySettings() {
</div>
</section>
<section className="min-h-0 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<section className="flex min-h-0 flex-col border-t border-[var(--color-border)]">
<PanelHeader
icon={<Database size={15} aria-hidden="true" />}
title={t('settings.memory.files')}
meta={isLoadingFiles ? t('common.loading') : `${files.length}`}
/>
{files.length > 3 ? (
<div className="border-b border-[var(--color-border)] p-3">
<SearchField
value={fileQuery}
onChange={setFileQuery}
placeholder={t('settings.memory.fileSearchPlaceholder')}
ariaLabel={t('settings.memory.fileSearchPlaceholder')}
clearLabel={t('settings.memory.clearSearch')}
/>
</div>
) : null}
<div className="max-h-[420px] overflow-y-auto p-2">
<div className="px-3 py-3">
<SearchField
value={fileQuery}
onChange={setFileQuery}
placeholder={t('settings.memory.fileSearchPlaceholder')}
ariaLabel={t('settings.memory.fileSearchPlaceholder')}
clearLabel={t('settings.memory.clearSearch')}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{files.length === 0 && !isLoadingFiles ? (
<EmptyState text={t('settings.memory.emptyFiles')} />
<EmptyState icon={<FileText size={18} />} text={t('settings.memory.emptyFiles')} />
) : filteredFiles.length === 0 ? (
<EmptyState text={t('settings.memory.noFileMatches')} />
<EmptyState icon={<Search size={18} />} text={t('settings.memory.noFileMatches')} />
) : (
filteredFiles.map((file) => (
<FileRow
@ -223,10 +230,10 @@ export function MemorySettings() {
)}
</div>
</section>
</div>
</aside>
<section className="min-h-0 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_12px_36px_rgba(15,23,42,0.04)]">
<div className="flex flex-col gap-3 border-b border-[var(--color-border)] p-4 lg:flex-row lg:items-center lg:justify-between">
<section className="min-h-0 overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<div className="grid gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-base font-semibold text-[var(--color-text-primary)]">
@ -246,6 +253,7 @@ export function MemorySettings() {
size="sm"
disabled={!selectedFile || !isDirty || isSaving}
onClick={() => selectedFile && updateDraft(selectedFile.content)}
icon={<RotateCcw size={14} aria-hidden="true" />}
>
{t('settings.memory.revert')}
</Button>
@ -255,7 +263,7 @@ export function MemorySettings() {
disabled={!selectedFile || !isDirty}
loading={isSaving}
onClick={() => void saveFile()}
icon={<span className="material-symbols-outlined text-[16px]">save</span>}
icon={<Save size={14} aria-hidden="true" />}
>
{t('common.save')}
</Button>
@ -263,9 +271,9 @@ export function MemorySettings() {
</div>
{selectedFile ? (
<div className="grid min-h-[520px] grid-rows-[minmax(300px,1fr)_minmax(240px,0.85fr)] 2xl:grid-cols-2 2xl:grid-rows-1">
<div className="grid min-h-[560px] grid-rows-[minmax(300px,1fr)_minmax(260px,0.95fr)] 2xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] 2xl:grid-rows-1">
<div className="min-h-0 border-b border-[var(--color-border)] 2xl:border-b-0 2xl:border-r">
<div className="flex h-9 items-center justify-between border-b border-[var(--color-border)] px-3 text-xs font-medium uppercase tracking-normal text-[var(--color-text-tertiary)]">
<div className="flex h-10 items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-xs font-medium uppercase tracking-normal text-[var(--color-text-tertiary)]">
<span>{t('settings.memory.editor')}</span>
<span>{formatBytes(selectedFile.bytes)}</span>
</div>
@ -274,11 +282,11 @@ export function MemorySettings() {
value={draftContent}
onChange={(event) => updateDraft(event.target.value)}
spellCheck={false}
className="h-[calc(100%-36px)] w-full resize-none overflow-auto bg-transparent p-5 font-mono text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
className="h-[calc(100%-40px)] w-full resize-none overflow-auto bg-transparent p-5 font-mono text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
</div>
<div className="min-h-0 overflow-y-auto">
<div className="flex h-9 items-center justify-between border-b border-[var(--color-border)] px-3 text-xs font-medium uppercase tracking-normal text-[var(--color-text-tertiary)]">
<div className="flex h-10 items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-xs font-medium uppercase tracking-normal text-[var(--color-text-tertiary)]">
<span>{t('settings.memory.preview')}</span>
<span>{selectedFile.updatedAt ? formatDate(selectedFile.updatedAt) : ''}</span>
</div>
@ -289,7 +297,7 @@ export function MemorySettings() {
</div>
) : (
<div className="flex min-h-[520px] items-center justify-center p-8">
<EmptyState text={isLoadingFile ? t('common.loading') : t('settings.memory.selectFile')} />
<EmptyState icon={<FileText size={20} />} text={isLoadingFile ? t('common.loading') : t('settings.memory.selectFile')} />
</div>
)}
</section>
@ -323,7 +331,7 @@ function SearchField({
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] pl-9 pr-9 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]"
className="h-10 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] pl-9 pr-9 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]"
/>
{value ? (
<button
@ -339,10 +347,13 @@ function SearchField({
)
}
function PanelHeader({ title, meta }: { title: string; meta: string }) {
function PanelHeader({ icon, title, meta }: { icon?: ReactNode; title: string; meta: string }) {
return (
<div className="flex h-12 items-center justify-between border-b border-[var(--color-border)] px-3">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">{title}</h3>
<div className="flex h-11 items-center justify-between border-b border-[var(--color-border)] px-3">
<h3 className="flex min-w-0 items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
{icon ? <span className="text-[var(--color-text-tertiary)]">{icon}</span> : null}
<span className="truncate">{title}</span>
</h3>
<span className="text-xs text-[var(--color-text-tertiary)]">{meta}</span>
</div>
)
@ -364,7 +375,7 @@ function ProjectRow({
type="button"
onClick={onSelect}
title={project.label}
className={`mb-1 w-full rounded-[var(--radius-md)] px-3 py-2.5 text-left transition-colors ${
className={`mb-1 w-full rounded-md px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] shadow-[inset_3px_0_0_var(--color-brand)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
@ -377,6 +388,7 @@ function ProjectRow({
<p className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]">{project.label}</p>
<div className="mt-1.5 flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.memory.fileCount', { count: project.fileCount })}</span>
{!project.exists ? <span>{t('settings.memory.missing')}</span> : null}
</div>
</button>
)
@ -391,11 +403,12 @@ function FileRow({
active: boolean
onSelect: () => void
}) {
const t = useTranslation()
return (
<button
type="button"
onClick={onSelect}
className={`mb-1 w-full rounded-[var(--radius-md)] px-3 py-2.5 text-left transition-colors ${
className={`mb-1 w-full rounded-md px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] shadow-[inset_3px_0_0_var(--color-brand)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
@ -406,14 +419,21 @@ function FileRow({
<FileText size={14} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
<span className="min-w-0 truncate text-sm font-semibold">{file.title}</span>
</span>
{file.type && <Badge>{file.type}</Badge>}
<span className="flex shrink-0 items-center gap-1.5">
{file.isIndex ? <Badge>{t('settings.memory.indexFile')}</Badge> : null}
{file.type && <Badge>{file.type}</Badge>}
</span>
</div>
<p className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]">{file.path}</p>
<p className="mt-1 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">{file.path}</p>
{file.description && (
<p className="mt-1 truncate text-xs leading-5 text-[var(--color-text-secondary)]">
{file.description}
</p>
)}
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
<span>{formatBytes(file.bytes)}</span>
{file.updatedAt ? <span>{formatDate(file.updatedAt)}</span> : null}
</div>
</button>
)
}
@ -426,10 +446,15 @@ function Badge({ children }: { children: string }) {
)
}
function EmptyState({ text }: { text: string }) {
function EmptyState({ icon, text }: { icon?: ReactNode; text: string }) {
return (
<div className="px-3 py-8 text-center text-sm text-[var(--color-text-tertiary)]">
{text}
<div className="grid place-items-center gap-2 px-3 py-8 text-center text-sm text-[var(--color-text-tertiary)]">
{icon ? (
<span className="flex h-9 w-9 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]">
{icon}
</span>
) : null}
<span>{text}</span>
</div>
)
}