/ src / components / agents / agent-files-editor.tsx
agent-files-editor.tsx
  1  'use client'
  2  
  3  import { useEffect, useState } from 'react'
  4  import { api } from '@/lib/app/api-client'
  5  import { errorMessage } from '@/lib/shared-utils'
  6  import { PersonalityBuilder } from './personality-builder'
  7  
  8  const FILES = ['SOUL.md', 'IDENTITY.md', 'USER.md', 'TOOLS.md', 'HEARTBEAT.md', 'MEMORY.md', 'AGENTS.md'] as const
  9  const GUIDED_FILES = new Set(['SOUL.md', 'IDENTITY.md', 'USER.md'])
 10  
 11  function makeInitialFiles(): Record<string, FileState> {
 12    const initial: Record<string, FileState> = {}
 13    for (const f of FILES) {
 14      initial[f] = { content: '', original: '', loading: true, saving: false }
 15    }
 16    return initial
 17  }
 18  
 19  interface FileState {
 20    content: string
 21    original: string
 22    loading: boolean
 23    saving: boolean
 24    error?: string
 25  }
 26  
 27  interface Props {
 28    agentId: string
 29  }
 30  
 31  export function AgentFilesEditor({ agentId }: Props) {
 32    const [activeTab, setActiveTab] = useState<string>(FILES[0])
 33    const [files, setFiles] = useState<Record<string, FileState>>(makeInitialFiles)
 34    const [guidedMode, setGuidedMode] = useState(false)
 35  
 36    // Reset to loading state when agentId changes
 37    const [prevAgentId, setPrevAgentId] = useState(agentId)
 38    if (agentId !== prevAgentId) {
 39      setPrevAgentId(agentId)
 40      setFiles(makeInitialFiles())
 41    }
 42  
 43    useEffect(() => {
 44      let cancelled = false
 45      api<Record<string, { content: string; error?: string }>>('GET', `/openclaw/agent-files?agentId=${agentId}`)
 46        .then((result) => {
 47          if (cancelled) return
 48          setFiles((prev) => {
 49            const next = { ...prev }
 50            for (const [name, data] of Object.entries(result)) {
 51              next[name] = {
 52                content: data.content,
 53                original: data.content,
 54                loading: false,
 55                saving: false,
 56                error: data.error,
 57              }
 58            }
 59            return next
 60          })
 61        })
 62        .catch((err: unknown) => {
 63          if (cancelled) return
 64          const message = errorMessage(err)
 65          setFiles((prev) => {
 66            const next = { ...prev }
 67            for (const f of FILES) {
 68              next[f] = { ...next[f], loading: false, error: message }
 69            }
 70            return next
 71          })
 72        })
 73      return () => { cancelled = true }
 74    }, [agentId])
 75  
 76    const handleContentChange = (filename: string, content: string) => {
 77      setFiles((prev) => ({
 78        ...prev,
 79        [filename]: { ...prev[filename], content },
 80      }))
 81    }
 82  
 83    const handleSave = async (filename: string) => {
 84      const file = files[filename]
 85      if (!file || file.content === file.original) return
 86  
 87      setFiles((prev) => ({
 88        ...prev,
 89        [filename]: { ...prev[filename], saving: true, error: undefined },
 90      }))
 91  
 92      try {
 93        await api('PUT', '/openclaw/agent-files', { agentId, filename, content: file.content })
 94        setFiles((prev) => ({
 95          ...prev,
 96          [filename]: { ...prev[filename], saving: false, original: prev[filename].content },
 97        }))
 98      } catch (err: unknown) {
 99        const message = errorMessage(err)
100        setFiles((prev) => ({
101          ...prev,
102          [filename]: { ...prev[filename], saving: false, error: message },
103        }))
104      }
105    }
106  
107    const handleGuidedSave = (content: string) => {
108      handleContentChange(activeTab, content)
109    }
110  
111    const current = files[activeTab]
112    const isDirty = current && current.content !== current.original
113    const showGuided = guidedMode && GUIDED_FILES.has(activeTab)
114  
115    return (
116      <div className="flex flex-col h-full">
117        {/* Tab bar */}
118        <div className="flex gap-0.5 px-2 pt-2 pb-1 overflow-x-auto shrink-0">
119          {FILES.map((f) => {
120            const fileState = files[f]
121            const dirty = fileState && fileState.content !== fileState.original
122            return (
123              <button
124                key={f}
125                onClick={() => setActiveTab(f)}
126                className={`px-2.5 py-1.5 rounded-[8px] text-[11px] font-600 cursor-pointer transition-all whitespace-nowrap
127                  ${activeTab === f
128                    ? 'bg-accent-soft text-accent-bright'
129                    : 'bg-transparent text-text-3 hover:text-text-2'}`}
130                style={{ fontFamily: 'inherit' }}
131              >
132                {f.replace('.md', '')}
133                {dirty && <span className="ml-1 text-amber-400">*</span>}
134              </button>
135            )
136          })}
137        </div>
138  
139        {/* Guided toggle for personality files */}
140        {GUIDED_FILES.has(activeTab) && (
141          <div className="px-3 py-1 shrink-0">
142            <button
143              onClick={() => setGuidedMode(!guidedMode)}
144              className={`text-[10px] font-600 px-2 py-0.5 rounded-[6px] cursor-pointer transition-all border-none
145                ${guidedMode ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.04] text-text-3 hover:text-text-2'}`}
146              style={{ fontFamily: 'inherit' }}
147            >
148              {guidedMode ? 'Raw Editor' : 'Guided Editor'}
149            </button>
150          </div>
151        )}
152  
153        {/* Editor area */}
154        <div className="flex-1 min-h-0 px-2 pb-2 overflow-y-auto">
155          {current?.loading ? (
156            <div className="flex items-center justify-center h-full text-[13px] text-text-3/50">Loading...</div>
157          ) : current?.error ? (
158            <div className="flex items-center justify-center h-full text-[13px] text-red-400">{current.error}</div>
159          ) : showGuided ? (
160            <div className="p-2">
161              <PersonalityBuilder
162                agentId={agentId}
163                fileType={activeTab as 'IDENTITY.md' | 'USER.md' | 'SOUL.md'}
164                content={current?.content ?? ''}
165                onSave={handleGuidedSave}
166              />
167            </div>
168          ) : (
169            <textarea
170              value={current?.content ?? ''}
171              onChange={(e) => handleContentChange(activeTab, e.target.value)}
172              className="w-full h-full resize-none rounded-[10px] border border-white/[0.06] bg-black/20 px-3 py-2.5
173                text-[13px] text-text font-mono leading-relaxed outline-none
174                placeholder:text-text-3/40 focus:border-white/[0.12] transition-colors"
175              placeholder={`${activeTab} content...`}
176              style={{ fontFamily: 'ui-monospace, monospace' }}
177            />
178          )}
179        </div>
180  
181        {/* Save bar */}
182        <div className="shrink-0 px-3 pb-2 flex items-center gap-2">
183          <button
184            onClick={() => handleSave(activeTab)}
185            disabled={!isDirty || current?.saving}
186            className="px-4 py-1.5 rounded-[8px] border-none bg-accent-bright text-white text-[12px] font-600
187              cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed transition-all hover:brightness-110"
188            style={{ fontFamily: 'inherit' }}
189          >
190            {current?.saving ? 'Saving...' : 'Save'}
191          </button>
192          {isDirty && (
193            <span className="text-[11px] text-amber-400/70">Unsaved changes</span>
194          )}
195        </div>
196      </div>
197    )
198  }