diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 6c162ed..7718c29 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,12 +1,20 @@ import { useCallback, useReducer, useRef, useState } from 'react'; import { ChatPanel } from './components/ChatPanel'; +import { ProjectStaleBanner } from './components/ProjectStaleBanner'; import { ProviderBar } from './components/ProviderBar'; import { SettingsPanel } from './components/SettingsPanel'; import { useWebSocket } from './hooks/useWebSocket'; import { useProviders } from './hooks/useProviders'; import { chatReducer } from './chatReducer'; import { toggleAppTab, type AppTab } from './appTab'; -import type { ClientMessage, ConfirmMode, ServerMessage, SettingsPayload } from './types'; +import type { + ClientMessage, + ConfirmMode, + ContextState, + ProjectState, + ServerMessage, + SettingsPayload, +} from './types'; /** Root application component. Manages tab state and wires WebSocket to chat. */ export function App() { @@ -14,6 +22,14 @@ export function App() { const [debugMode, setDebugMode] = useState(false); const [confirmMode, setConfirmMode] = useState('guard'); const [settings, setSettings] = useState(null); + const [project, setProject] = useState({ name: null, slug: null }); + const [context, setContext] = useState({ + globalInstructions: '', + projectInstructions: '', + globalMemories: '', + projectMemories: '', + }); + const [projectStale, setProjectStale] = useState(null); const [chatState, dispatch] = useReducer(chatReducer, { messages: [], streaming: false }); const sendRef = useRef<(msg: ClientMessage) => void>(() => {}); @@ -31,6 +47,8 @@ export function App() { switch (msg.type) { case 'ready': sendMsg({ type: 'get_settings' }); + sendMsg({ type: 'get_project' }); + sendMsg({ type: 'get_context' }); break; case 'stream_start': if (expectDiagnosticStreamRef.current) { @@ -78,6 +96,23 @@ export function App() { case 'history': dispatch({ type: 'LOAD_HISTORY', messages: msg.messages }); break; + case 'project': + setProject({ name: msg.name, slug: msg.slug }); + break; + case 'context': + setContext({ + globalInstructions: msg.globalInstructions, + projectInstructions: msg.projectInstructions, + globalMemories: msg.globalMemories, + projectMemories: msg.projectMemories, + }); + break; + case 'context_saved': + setProjectStale(null); + break; + case 'project_stale': + setProjectStale(msg.summary); + break; } }, [debugMode, initFromLastChoice], @@ -138,6 +173,30 @@ export function App() { sendMsg({ type: 'open_url', url }); } + function handleSetProject(name: string) { + sendMsg({ type: 'set_project', name }); + } + + function handleClearProject() { + sendMsg({ type: 'clear_project' }); + } + + function handleSaveInstructions(scope: 'global' | 'project', content: string) { + sendMsg({ type: 'save_instructions', scope, content }); + } + + function handleSaveMemories(scope: 'global' | 'project', content: string) { + sendMsg({ type: 'save_memories', scope, content }); + } + + function handleRefreshProjectMemories() { + sendMsg({ type: 'refresh_project_memories', provider, model }); + } + + function handleDismissStale() { + setProjectStale(null); + } + function handleCloseSettings() { setTab('chat'); } @@ -162,6 +221,12 @@ export function App() { onToggleToolFold={handleToggleToolFold} /> + + {tab === 'settings' && ( <> + + + ); +} diff --git a/ui/src/components/ProjectStaleBanner/index.ts b/ui/src/components/ProjectStaleBanner/index.ts new file mode 100644 index 0000000..e32f67c --- /dev/null +++ b/ui/src/components/ProjectStaleBanner/index.ts @@ -0,0 +1 @@ +export { ProjectStaleBanner } from './ProjectStaleBanner.js'; diff --git a/ui/src/components/SettingsPanel/SettingsPanel.tsx b/ui/src/components/SettingsPanel/SettingsPanel.tsx index 36e7544..f807438 100644 --- a/ui/src/components/SettingsPanel/SettingsPanel.tsx +++ b/ui/src/components/SettingsPanel/SettingsPanel.tsx @@ -1,15 +1,21 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { X } from 'lucide-react'; import { ApiKeyField, getKeyInputValue } from '../ApiKeyField'; -import type { ProvidersRegistry, SettingsPayload } from '../../types'; +import type { ContextState, ProjectState, ProvidersRegistry, SettingsPayload } from '../../types'; interface SettingsPanelProps { providers: ProvidersRegistry; settings: SettingsPayload | null; + project: ProjectState; + context: ContextState; onSave: (payload: { keys: Record }) => void; onClearKey: (provider: string) => void; onOpenUrl: (url: string) => void; onClose: () => void; + onSetProject: (name: string) => void; + onClearProject: () => void; + onSaveInstructions: (scope: 'global' | 'project', content: string) => void; + onSaveMemories: (scope: 'global' | 'project', content: string) => void; } const KEY_PLACEHOLDERS: Record = { @@ -20,16 +26,53 @@ const KEY_PLACEHOLDERS: Record = { const PROVIDER_ORDER = ['openai', 'anthropic', 'gemini']; -/** Settings panel for API keys. */ +const TEXTAREA_CLASS = + 'w-full resize-none rounded-default border border-border bg-surface2 px-2.5 py-2 text-[13px] text-text outline-none focus:outline focus:outline-1 focus:outline-accent'; + +/** Settings panel for API keys, project context, instructions, and memories. */ export function SettingsPanel({ providers, settings, + project, + context, onSave, onClearKey, onOpenUrl, onClose, + onSetProject, + onClearProject, + onSaveInstructions, + onSaveMemories, }: SettingsPanelProps) { const [feedback, setFeedback] = useState(''); + const [projectName, setProjectName] = useState(project.name ?? ''); + const [globalInstructions, setGlobalInstructions] = useState(context.globalInstructions); + const [projectInstructions, setProjectInstructions] = useState(context.projectInstructions); + const [globalMemories, setGlobalMemories] = useState(context.globalMemories); + const [projectMemories, setProjectMemories] = useState(context.projectMemories); + + useEffect(() => { + setProjectName(project.name ?? ''); + }, [project.name]); + + useEffect(() => { + setGlobalInstructions(context.globalInstructions); + setProjectInstructions(context.projectInstructions); + setGlobalMemories(context.globalMemories); + setProjectMemories(context.projectMemories); + }, [ + context.globalInstructions, + context.projectInstructions, + context.globalMemories, + context.projectMemories, + ]); + + const projectActive = project.slug !== null; + + function showFeedback(message: string) { + setFeedback(message); + setTimeout(() => setFeedback(''), 2500); + } function handleSave() { const keys: Record = {}; @@ -37,8 +80,28 @@ export function SettingsPanel({ keys[id] = getKeyInputValue(id); } onSave({ keys }); - setFeedback('Saved ✓'); - setTimeout(() => setFeedback(''), 2500); + showFeedback('Saved ✓'); + } + + function handleSetProject() { + const trimmed = projectName.trim(); + if (!trimmed) { + return; + } + onSetProject(trimmed); + showFeedback('Project updated ✓'); + } + + function handleSaveInstructions(scope: 'global' | 'project') { + const content = scope === 'global' ? globalInstructions : projectInstructions; + onSaveInstructions(scope, content); + showFeedback('Instructions saved ✓'); + } + + function handleSaveMemories(scope: 'global' | 'project') { + const content = scope === 'global' ? globalMemories : projectMemories; + onSaveMemories(scope, content); + showFeedback('Memories saved ✓'); } function handleExternalLink(e: React.MouseEvent, url: string) { @@ -104,6 +167,152 @@ export function SettingsPanel({ ))} +
+

+ Project +

+
+ setProjectName(event.target.value)} + placeholder="My Live Set" + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleSetProject(); + } + }} + /> + +
+ {projectActive && ( +
+ slug: {project.slug} + +
+ )} +
+ +
+

+ Instructions +

+
+
+ +