mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
ca5115efba
commit
7a2d0444ff
@ -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[] = [
|
||||
{
|
||||
|
||||
@ -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']
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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} 个文件已更改',
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user