| 1 | import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; |
| 2 | import { |
| 3 | ArrowUp, |
| 4 | Brain, |
| 5 | Braces, |
| 6 | CircleStop, |
| 7 | Clock3, |
| 8 | FilePenLine, |
| 9 | FileText, |
| 10 | Film, |
| 11 | Folder, |
| 12 | FolderPlus, |
| 13 | Files, |
| 14 | Image as ImageIcon, |
| 15 | Menu, |
| 16 | Moon, |
| 17 | PanelLeftClose, |
| 18 | PanelLeftOpen, |
| 19 | PanelRight, |
| 20 | Plus, |
| 21 | Save, |
| 22 | Search, |
| 23 | Settings, |
| 24 | ListChecks, |
| 25 | Terminal, |
| 26 | Trash2, |
| 27 | Sun, |
| 28 | Wrench, |
| 29 | X, |
| 30 | } from 'lucide-react'; |
| 31 | import ReactMarkdown from 'react-markdown'; |
| 32 | import remarkGfm from 'remark-gfm'; |
| 33 | import {deleteSession, getAgentConfig, getArtifacts, getHistory, getSessions, saveAgentConfig, sendMessage, startAgent, stopAgent, subscribeToEvents, uploadWorkspaceFile} from './api'; |
| 34 | import {ArtifactsView, StoryboardPanel} from './ArtifactViews'; |
| 35 | import {applyAgentEvent, appendLocalUser, composeAgentPrompt, createChatState, humanize} from './events'; |
| 36 | import {matchingSlashCommands, shouldShowSlashCommands, type SlashCommandMatch} from './slashCommands'; |
| 37 | import {applyTheme, resolveTheme, THEME_STORAGE_KEY, type Theme} from './theme'; |
| 38 | import type {AgentConfig, AgentEvent, Artifact, ChatState, ConfigSection, Message, SessionSummary, WorkspaceUpload} from './types'; |
| 39 | |
| 40 | const CONTEXT_TARGET = 160_000; |
| 41 | |
| 42 | type WorkspaceView = 'workspace' | 'artifacts' | 'settings'; |
| 43 | |
| 44 | export default function App() { |
| 45 | const [sessions, setSessions] = useState<SessionSummary[]>([]); |
| 46 | const [selectedSessionId, setSelectedSessionId] = useState(''); |
| 47 | const [chat, setChat] = useState<ChatState>(() => createChatState()); |
| 48 | const [artifacts, setArtifacts] = useState<Artifact[]>([]); |
| 49 | const [workspaceView, setWorkspaceView] = useState<WorkspaceView>('workspace'); |
| 50 | const [agentReady, setAgentReady] = useState(false); |
| 51 | const [sidebarOpen, setSidebarOpen] = useState(true); |
| 52 | const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); |
| 53 | const [storyboardPanelOpen, setStoryboardPanelOpen] = useState(false); |
| 54 | const [storyboardCount, setStoryboardCount] = useState(0); |
| 55 | const [theme, setTheme] = useState<Theme>(() => resolveTheme(document.documentElement.dataset.theme, false)); |
| 56 | const [draft, setDraft] = useState(''); |
| 57 | const [workspaceUploads, setWorkspaceUploads] = useState<WorkspaceUpload[]>([]); |
| 58 | const [uploadingFiles, setUploadingFiles] = useState(false); |
| 59 | const [loadError, setLoadError] = useState(''); |
| 60 | const [newProjectOpen, setNewProjectOpen] = useState(false); |
| 61 | const [newProjectName, setNewProjectName] = useState(''); |
| 62 | const [newProjectError, setNewProjectError] = useState(''); |
| 63 | const [creatingProject, setCreatingProject] = useState(false); |
| 64 | const [pendingDelete, setPendingDelete] = useState<SessionSummary>(); |
| 65 | const [deleting, setDeleting] = useState(false); |
| 66 | const scrollRef = useRef<HTMLDivElement>(null); |
| 67 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 68 | const fileInputRef = useRef<HTMLInputElement>(null); |
| 69 | |
| 70 | const selectedSession = sessions.find((session) => session.sessionId === selectedSessionId); |
| 71 | const slashMatches = useMemo(() => matchingSlashCommands(draft), [draft]); |
| 72 | const showSlashCommands = shouldShowSlashCommands(draft, chat.busy); |
| 73 | const runningRender = useMemo( |
| 74 | () => [...chat.messages].reverse().find((message) => message.role === 'activity' |
| 75 | && message.status === 'running' |
| 76 | && (message.tool || '').toLowerCase().includes('render_video')), |
| 77 | [chat.messages], |
| 78 | ); |
| 79 | |
| 80 | useEffect(() => { |
| 81 | applyTheme(theme); |
| 82 | try { |
| 83 | window.localStorage.setItem(THEME_STORAGE_KEY, theme); |
| 84 | } catch { |
| 85 | // Theme still applies when persistence is unavailable. |
| 86 | } |
| 87 | }, [theme]); |
| 88 | |
| 89 | const refreshSessions = useCallback(async () => { |
| 90 | const state = await getSessions(); |
| 91 | setSessions(state.sessions); |
| 92 | return state; |
| 93 | }, []); |
| 94 | |
| 95 | const refreshArtifacts = useCallback(async (sessionId: string) => { |
| 96 | if (!sessionId) { |
| 97 | setArtifacts([]); |
| 98 | return; |
| 99 | } |
| 100 | const payload = await getArtifacts(sessionId); |
| 101 | setArtifacts(payload.artifacts); |
| 102 | }, []); |
| 103 | |
| 104 | useEffect(() => subscribeToEvents((event) => { |
| 105 | if (event.type === 'sessions_changed') { |
| 106 | setSessions(event.sessions || []); |
| 107 | if (event.activeSessionId) setSelectedSessionId(event.activeSessionId); |
| 108 | return; |
| 109 | } |
| 110 | if (event.type === 'bridge_status') { |
| 111 | if (event.status === 'ready' || event.status === 'starting') setAgentReady(true); |
| 112 | if (event.status === 'stopped' || event.status === 'error') setAgentReady(false); |
| 113 | } |
| 114 | if (event.type === 'session') { |
| 115 | const sessionId = event.session?.active_session_id || event.session?.session?.session_id || ''; |
| 116 | if (sessionId) { |
| 117 | setSelectedSessionId(sessionId); |
| 118 | void refreshSessions(); |
| 119 | void refreshArtifacts(sessionId); |
| 120 | } |
| 121 | } |
| 122 | setChat((current) => applyAgentEvent(current, event)); |
| 123 | }, () => undefined), [refreshArtifacts, refreshSessions]); |
| 124 | |
| 125 | useEffect(() => { |
| 126 | let cancelled = false; |
| 127 | void (async () => { |
| 128 | try { |
| 129 | const state = await refreshSessions(); |
| 130 | if (cancelled || !state.activeSessionId) return; |
| 131 | setSelectedSessionId(state.activeSessionId); |
| 132 | const [history] = await Promise.all([ |
| 133 | getHistory(state.activeSessionId), |
| 134 | refreshArtifacts(state.activeSessionId), |
| 135 | startAgent({sessionId: state.activeSessionId}), |
| 136 | ]); |
| 137 | if (!cancelled) { |
| 138 | setChat(createChatState(history.messages)); |
| 139 | setAgentReady(true); |
| 140 | } |
| 141 | } catch (error) { |
| 142 | if (!cancelled) setLoadError(error instanceof Error ? error.message : String(error)); |
| 143 | } |
| 144 | })(); |
| 145 | return () => { |
| 146 | cancelled = true; |
| 147 | }; |
| 148 | }, [refreshArtifacts, refreshSessions]); |
| 149 | |
| 150 | useEffect(() => { |
| 151 | if (!runningRender || !selectedSessionId) return; |
| 152 | void refreshArtifacts(selectedSessionId); |
| 153 | const interval = window.setInterval(() => void refreshArtifacts(selectedSessionId), 2_000); |
| 154 | return () => { |
| 155 | window.clearInterval(interval); |
| 156 | void refreshArtifacts(selectedSessionId); |
| 157 | }; |
| 158 | }, [refreshArtifacts, runningRender, selectedSessionId]); |
| 159 | |
| 160 | useEffect(() => { |
| 161 | const element = scrollRef.current; |
| 162 | if (!element) return; |
| 163 | element.scrollTo({top: element.scrollHeight, behavior: chat.busy ? 'smooth' : 'auto'}); |
| 164 | }, [chat.messages, chat.busy]); |
| 165 | |
| 166 | useEffect(() => { |
| 167 | const textarea = textareaRef.current; |
| 168 | if (!textarea) return; |
| 169 | textarea.style.height = '0px'; |
| 170 | textarea.style.height = `${Math.min(168, Math.max(28, textarea.scrollHeight))}px`; |
| 171 | }, [draft]); |
| 172 | |
| 173 | useEffect(() => { |
| 174 | setWorkspaceUploads([]); |
| 175 | if (fileInputRef.current) fileInputRef.current.value = ''; |
| 176 | }, [selectedSessionId]); |
| 177 | |
| 178 | async function openSession(sessionId: string) { |
| 179 | if (!sessionId || sessionId === selectedSessionId) { |
| 180 | setMobileSidebarOpen(false); |
| 181 | return; |
| 182 | } |
| 183 | setLoadError(''); |
| 184 | setSelectedSessionId(sessionId); |
| 185 | setMobileSidebarOpen(false); |
| 186 | setChat(createChatState()); |
| 187 | try { |
| 188 | const [history] = await Promise.all([ |
| 189 | getHistory(sessionId), |
| 190 | refreshArtifacts(sessionId), |
| 191 | startAgent({sessionId}), |
| 192 | ]); |
| 193 | setChat(createChatState(history.messages)); |
| 194 | setAgentReady(true); |
| 195 | } catch (error) { |
| 196 | setLoadError(error instanceof Error ? error.message : String(error)); |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | function openNewProjectDialog() { |
| 201 | setNewProjectName(''); |
| 202 | setNewProjectError(''); |
| 203 | setNewProjectOpen(true); |
| 204 | } |
| 205 | |
| 206 | async function newProject() { |
| 207 | const projectName = newProjectName.trim(); |
| 208 | if (!projectName || creatingProject) return; |
| 209 | setCreatingProject(true); |
| 210 | setNewProjectError(''); |
| 211 | setLoadError(''); |
| 212 | setMobileSidebarOpen(false); |
| 213 | try { |
| 214 | await startAgent({newSession: true, projectName}); |
| 215 | setAgentReady(true); |
| 216 | await new Promise((resolve) => window.setTimeout(resolve, 450)); |
| 217 | const state = await refreshSessions(); |
| 218 | setSelectedSessionId(state.activeSessionId); |
| 219 | setChat(createChatState()); |
| 220 | setArtifacts([]); |
| 221 | setWorkspaceView('workspace'); |
| 222 | setNewProjectOpen(false); |
| 223 | setNewProjectName(''); |
| 224 | textareaRef.current?.focus(); |
| 225 | } catch (error) { |
| 226 | setNewProjectError(error instanceof Error ? error.message : String(error)); |
| 227 | } finally { |
| 228 | setCreatingProject(false); |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | async function submit() { |
| 233 | const text = draft.trim(); |
| 234 | if (!text || chat.busy || uploadingFiles) return; |
| 235 | setLoadError(''); |
| 236 | setDraft(''); |
| 237 | setChat((current) => appendLocalUser(current, text)); |
| 238 | try { |
| 239 | if (!agentReady) { |
| 240 | await startAgent(selectedSessionId ? {sessionId: selectedSessionId} : {newSession: true}); |
| 241 | setAgentReady(true); |
| 242 | } |
| 243 | const outbound = composeAgentPrompt(text, workspaceUploads.map((file) => file.path)); |
| 244 | await sendMessage(outbound); |
| 245 | setWorkspaceUploads([]); |
| 246 | } catch (error) { |
| 247 | const event: AgentEvent = {type: 'error', message: error instanceof Error ? error.message : String(error)}; |
| 248 | setChat((current) => applyAgentEvent(current, event)); |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | async function uploadFiles(files: FileList | null) { |
| 253 | const sessionId = selectedSessionId; |
| 254 | if (!files?.length) return; |
| 255 | if (!sessionId) { |
| 256 | setLoadError('Create or select a project before uploading files'); |
| 257 | return; |
| 258 | } |
| 259 | setUploadingFiles(true); |
| 260 | setLoadError(''); |
| 261 | let uploadedAny = false; |
| 262 | try { |
| 263 | for (const file of Array.from(files)) { |
| 264 | const result = await uploadWorkspaceFile(sessionId, file); |
| 265 | uploadedAny = true; |
| 266 | setWorkspaceUploads((current) => [ |
| 267 | ...current.filter((item) => item.path !== result.file.path), |
| 268 | result.file, |
| 269 | ]); |
| 270 | } |
| 271 | } catch (error) { |
| 272 | setLoadError(error instanceof Error ? error.message : String(error)); |
| 273 | } finally { |
| 274 | if (fileInputRef.current) fileInputRef.current.value = ''; |
| 275 | if (uploadedAny) await refreshArtifacts(sessionId); |
| 276 | setUploadingFiles(false); |
| 277 | textareaRef.current?.focus(); |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | async function stop() { |
| 282 | await stopAgent(); |
| 283 | setAgentReady(false); |
| 284 | setChat((current) => ({...current, busy: false})); |
| 285 | } |
| 286 | |
| 287 | async function confirmDelete() { |
| 288 | if (!pendingDelete || deleting) return; |
| 289 | const sessionId = pendingDelete.sessionId; |
| 290 | const deletingSelected = sessionId === selectedSessionId; |
| 291 | setDeleting(true); |
| 292 | setLoadError(''); |
| 293 | try { |
| 294 | const state = await deleteSession(sessionId); |
| 295 | setSessions(state.sessions); |
| 296 | setPendingDelete(undefined); |
| 297 | if (deletingSelected) { |
| 298 | setSelectedSessionId(''); |
| 299 | setChat(createChatState()); |
| 300 | setArtifacts([]); |
| 301 | setAgentReady(false); |
| 302 | if (state.activeSessionId) await openSession(state.activeSessionId); |
| 303 | } |
| 304 | } catch (error) { |
| 305 | setLoadError(error instanceof Error ? error.message : String(error)); |
| 306 | } finally { |
| 307 | setDeleting(false); |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | const contextPercent = Math.min(100, Math.round((chat.promptTokens / CONTEXT_TARGET) * 100)); |
| 312 | const hasConversation = chat.messages.length > 0; |
| 313 | |
| 314 | return ( |
| 315 | <div className="app-shell"> |
| 316 | <Sidebar |
| 317 | open={sidebarOpen} |
| 318 | mobileOpen={mobileSidebarOpen} |
| 319 | sessions={sessions} |
| 320 | selectedSessionId={selectedSessionId} |
| 321 | activeView={workspaceView} |
| 322 | onToggle={() => setSidebarOpen((value) => !value)} |
| 323 | onMobileClose={() => setMobileSidebarOpen(false)} |
| 324 | onNew={openNewProjectDialog} |
| 325 | onSelect={(sessionId) => void openSession(sessionId)} |
| 326 | onWorkspace={() => { |
| 327 | setWorkspaceView('workspace'); |
| 328 | setMobileSidebarOpen(false); |
| 329 | }} |
| 330 | onArtifacts={() => { |
| 331 | setWorkspaceView('artifacts'); |
| 332 | setMobileSidebarOpen(false); |
| 333 | }} |
| 334 | onSettings={() => { |
| 335 | setWorkspaceView('settings'); |
| 336 | setMobileSidebarOpen(false); |
| 337 | }} |
| 338 | onDelete={setPendingDelete} |
| 339 | /> |
| 340 | |
| 341 | <main className="workspace-main"> |
| 342 | {workspaceView === 'workspace' ? ( |
| 343 | <div className="workspace-utility-bar"> |
| 344 | <button className="icon-button mobile-only" onClick={() => setMobileSidebarOpen(true)} aria-label="Open navigation"> |
| 345 | <Menu size={19} /> |
| 346 | </button> |
| 347 | {!sidebarOpen && ( |
| 348 | <button className="icon-button desktop-only" onClick={() => setSidebarOpen(true)} aria-label="Open navigation"> |
| 349 | <PanelLeftOpen size={18} /> |
| 350 | </button> |
| 351 | )} |
| 352 | <span className="workspace-utility-spacer" /> |
| 353 | <div className="workspace-utility-actions"> |
| 354 | <ThemeToggle theme={theme} onToggle={() => setTheme((current) => current === 'dark' ? 'light' : 'dark')} /> |
| 355 | <button className="icon-button artifact-toggle" onClick={() => setStoryboardPanelOpen((value) => !value)} aria-label="Toggle storyboard preview"> |
| 356 | <PanelRight size={18} /> |
| 357 | {storyboardCount > 0 && <span className="count-badge">{storyboardCount}</span>} |
| 358 | </button> |
| 359 | </div> |
| 360 | </div> |
| 361 | ) : ( |
| 362 | <header className="workspace-header"> |
| 363 | <div className="workspace-title-row"> |
| 364 | <button className="icon-button mobile-only" onClick={() => setMobileSidebarOpen(true)} aria-label="Open navigation"> |
| 365 | <Menu size={19} /> |
| 366 | </button> |
| 367 | {!sidebarOpen && ( |
| 368 | <button className="icon-button desktop-only" onClick={() => setSidebarOpen(true)} aria-label="Open navigation"> |
| 369 | <PanelLeftOpen size={18} /> |
| 370 | </button> |
| 371 | )} |
| 372 | <div className="workspace-title-copy"> |
| 373 | <strong>{workspaceView === 'settings' ? 'Settings' : 'Artifacts'}</strong> |
| 374 | {workspaceView === 'settings' && <span>configs/agent.local.yaml</span>} |
| 375 | </div> |
| 376 | </div> |
| 377 | <ThemeToggle theme={theme} onToggle={() => setTheme((current) => current === 'dark' ? 'light' : 'dark')} /> |
| 378 | </header> |
| 379 | )} |
| 380 | |
| 381 | {workspaceView === 'workspace' ? ( |
| 382 | <div className="conversation" ref={scrollRef}> |
| 383 | {!hasConversation ? ( |
| 384 | <EmptyState theme={theme} /> |
| 385 | ) : ( |
| 386 | <div className="message-stream"> |
| 387 | {chat.messages.map((message) => <MessageRow key={message.id} message={message} />)} |
| 388 | {chat.busy && <ThinkingRow messages={chat.messages} />} |
| 389 | </div> |
| 390 | )} |
| 391 | </div> |
| 392 | ) : workspaceView === 'artifacts' ? ( |
| 393 | <ArtifactsView session={selectedSession} artifacts={artifacts} /> |
| 394 | ) : ( |
| 395 | <SettingsView /> |
| 396 | )} |
| 397 | |
| 398 | {workspaceView === 'workspace' && <div className="composer-zone"> |
| 399 | {loadError && ( |
| 400 | <div className="inline-error" role="alert"> |
| 401 | <span>{loadError}</span> |
| 402 | <button onClick={() => setLoadError('')} aria-label="Dismiss error"><X size={15} /></button> |
| 403 | </div> |
| 404 | )} |
| 405 | {showSlashCommands && <SlashCommandMenu matches={slashMatches} contextPercent={contextPercent} onSelect={(command) => { |
| 406 | setDraft(command); |
| 407 | textareaRef.current?.focus(); |
| 408 | }} />} |
| 409 | <div className={`composer ${chat.busy ? 'is-busy' : ''}`}> |
| 410 | <textarea |
| 411 | ref={textareaRef} |
| 412 | value={draft} |
| 413 | onChange={(event) => setDraft(event.target.value)} |
| 414 | onKeyDown={(event) => { |
| 415 | if (event.key === 'Tab' && slashMatches[0]) { |
| 416 | event.preventDefault(); |
| 417 | setDraft(slashMatches[0].name); |
| 418 | return; |
| 419 | } |
| 420 | if (event.key === 'Enter' && !event.shiftKey) { |
| 421 | event.preventDefault(); |
| 422 | void submit(); |
| 423 | } |
| 424 | }} |
| 425 | placeholder="Describe what you want to make" |
| 426 | aria-label="Message ViMax" |
| 427 | disabled={chat.busy} |
| 428 | rows={1} |
| 429 | /> |
| 430 | {(workspaceUploads.length > 0 || uploadingFiles) && ( |
| 431 | <div className="composer-attachments" aria-live="polite"> |
| 432 | {workspaceUploads.map((file) => ( |
| 433 | <span className="composer-attachment" key={file.path} title={file.path}> |
| 434 | <FileText size={13} /> |
| 435 | <span>{file.name}</span> |
| 436 | <button |
| 437 | type="button" |
| 438 | onClick={() => setWorkspaceUploads((current) => current.filter((item) => item.path !== file.path))} |
| 439 | aria-label={`Remove ${file.name} from this message`} |
| 440 | > |
| 441 | <X size={12} /> |
| 442 | </button> |
| 443 | </span> |
| 444 | ))} |
| 445 | {uploadingFiles && <span className="composer-uploading">Uploading…</span>} |
| 446 | </div> |
| 447 | )} |
| 448 | <div className="composer-controls"> |
| 449 | <input |
| 450 | ref={fileInputRef} |
| 451 | className="composer-file-input" |
| 452 | type="file" |
| 453 | multiple |
| 454 | onChange={(event) => void uploadFiles(event.currentTarget.files)} |
| 455 | tabIndex={-1} |
| 456 | /> |
| 457 | <button |
| 458 | type="button" |
| 459 | className="composer-add" |
| 460 | onClick={() => fileInputRef.current?.click()} |
| 461 | disabled={!selectedSessionId || uploadingFiles || chat.busy} |
| 462 | aria-label="Upload files to workspace" |
| 463 | aria-busy={uploadingFiles} |
| 464 | title={selectedSessionId ? 'Upload files to workspace' : 'Create or select a project first'} |
| 465 | > |
| 466 | <Plus size={20} /> |
| 467 | </button> |
| 468 | <div className="composer-spacer" /> |
| 469 | {chat.busy ? ( |
| 470 | <button className="send-button stop" onClick={() => void stop()} aria-label="Stop generation"><CircleStop size={18} /></button> |
| 471 | ) : ( |
| 472 | <button className="send-button" onClick={() => void submit()} disabled={!draft.trim() || uploadingFiles} aria-label="Send message"><ArrowUp size={19} /></button> |
| 473 | )} |
| 474 | </div> |
| 475 | </div> |
| 476 | </div>} |
| 477 | </main> |
| 478 | |
| 479 | <StoryboardPanel |
| 480 | open={storyboardPanelOpen && workspaceView === 'workspace'} |
| 481 | artifacts={artifacts} |
| 482 | activeRenderStage={runningRender?.stage} |
| 483 | onClose={() => setStoryboardPanelOpen(false)} |
| 484 | onCountChange={setStoryboardCount} |
| 485 | /> |
| 486 | <DeleteProjectDialog |
| 487 | session={pendingDelete} |
| 488 | deleting={deleting} |
| 489 | onCancel={() => !deleting && setPendingDelete(undefined)} |
| 490 | onConfirm={() => void confirmDelete()} |
| 491 | /> |
| 492 | <NewProjectDialog |
| 493 | open={newProjectOpen} |
| 494 | name={newProjectName} |
| 495 | error={newProjectError} |
| 496 | creating={creatingProject} |
| 497 | onNameChange={(value) => { |
| 498 | setNewProjectName(value); |
| 499 | setNewProjectError(''); |
| 500 | }} |
| 501 | onCancel={() => { |
| 502 | if (creatingProject) return; |
| 503 | setNewProjectOpen(false); |
| 504 | setNewProjectError(''); |
| 505 | }} |
| 506 | onConfirm={() => void newProject()} |
| 507 | /> |
| 508 | </div> |
| 509 | ); |
| 510 | } |
| 511 | |
| 512 | function Sidebar({open, mobileOpen, sessions, selectedSessionId, activeView, onToggle, onMobileClose, onNew, onSelect, onWorkspace, onArtifacts, onSettings, onDelete}: { |
| 513 | open: boolean; |
| 514 | mobileOpen: boolean; |
| 515 | sessions: SessionSummary[]; |
| 516 | selectedSessionId: string; |
| 517 | activeView: WorkspaceView; |
| 518 | onToggle: () => void; |
| 519 | onMobileClose: () => void; |
| 520 | onNew: () => void; |
| 521 | onSelect: (sessionId: string) => void; |
| 522 | onWorkspace: () => void; |
| 523 | onArtifacts: () => void; |
| 524 | onSettings: () => void; |
| 525 | onDelete: (session: SessionSummary) => void; |
| 526 | }) { |
| 527 | return ( |
| 528 | <> |
| 529 | {mobileOpen && <button className="sidebar-scrim" onClick={onMobileClose} aria-label="Close navigation" />} |
| 530 | <aside className={`sidebar ${open ? 'is-open' : 'is-collapsed'} ${mobileOpen ? 'is-mobile-open' : ''}`}> |
| 531 | <div className="sidebar-brand"> |
| 532 | <strong>ViMax</strong> |
| 533 | <button className="icon-button sidebar-collapse desktop-only" onClick={onToggle} aria-label="Collapse navigation"> |
| 534 | <PanelLeftClose size={17} /> |
| 535 | </button> |
| 536 | <button className="icon-button mobile-only" onClick={onMobileClose} aria-label="Close navigation"><X size={18} /></button> |
| 537 | </div> |
| 538 | <nav className="primary-nav" aria-label="Primary navigation"> |
| 539 | <button onClick={onNew}><FolderPlus size={17} /><span>New project</span></button> |
| 540 | <button className={activeView === 'workspace' ? 'is-active' : ''} onClick={onWorkspace}><Folder size={17} /><span>Workspace</span></button> |
| 541 | <button className={activeView === 'artifacts' ? 'is-active' : ''} onClick={onArtifacts}><Files size={17} /><span>Artifacts</span></button> |
| 542 | <button className={activeView === 'settings' ? 'is-active' : ''} onClick={onSettings}><Settings size={17} /><span>Settings</span></button> |
| 543 | </nav> |
| 544 | <div className="session-section"> |
| 545 | <div className="section-label"><span>Projects</span><span>{sessions.length}</span></div> |
| 546 | <div className="session-list"> |
| 547 | {sessions.map((session) => ( |
| 548 | <div |
| 549 | key={session.sessionId} |
| 550 | className={`session-item ${session.sessionId === selectedSessionId ? 'is-selected' : ''}`} |
| 551 | > |
| 552 | <button className="session-open" onClick={() => onSelect(session.sessionId)}> |
| 553 | <span className="session-copy"> |
| 554 | <strong>{sessionTitle(session)}</strong> |
| 555 | <small>{relativeTime(session.updatedAt)} · {stageLabel(session.stage)}</small> |
| 556 | </span> |
| 557 | </button> |
| 558 | <button className="session-delete" onClick={() => onDelete(session)} aria-label={`Delete ${sessionTitle(session)}`} title="Delete project"> |
| 559 | <Trash2 size={14} /> |
| 560 | </button> |
| 561 | </div> |
| 562 | ))} |
| 563 | {sessions.length === 0 && <span className="empty-list">No projects yet</span>} |
| 564 | </div> |
| 565 | </div> |
| 566 | <div className="sidebar-footer"> |
| 567 | <span className="avatar">V</span> |
| 568 | <div><strong>Local workspace</strong><small>Local · ViMax</small></div> |
| 569 | </div> |
| 570 | </aside> |
| 571 | </> |
| 572 | ); |
| 573 | } |
| 574 | |
| 575 | function EmptyState({theme}: {theme: Theme}) { |
| 576 | return ( |
| 577 | <section className="empty-state"> |
| 578 | <img className="empty-state-logo" src={theme === 'dark' ? '/vimax-light.svg' : '/vimax-dark.svg'} alt="ViMax" /> |
| 579 | <h1>What should we create?</h1> |
| 580 | </section> |
| 581 | ); |
| 582 | } |
| 583 | |
| 584 | function ThemeToggle({theme, onToggle}: {theme: Theme; onToggle: () => void}) { |
| 585 | const dark = theme === 'dark'; |
| 586 | return ( |
| 587 | <button |
| 588 | type="button" |
| 589 | className="icon-button theme-toggle" |
| 590 | onClick={onToggle} |
| 591 | aria-label={dark ? 'Use light mode' : 'Use dark mode'} |
| 592 | aria-pressed={dark} |
| 593 | title={dark ? 'Light mode' : 'Dark mode'} |
| 594 | > |
| 595 | {dark ? <Sun size={18} /> : <Moon size={18} />} |
| 596 | </button> |
| 597 | ); |
| 598 | } |
| 599 | |
| 600 | function MessageRow({message}: {message: Message}) { |
| 601 | if (message.role === 'activity') return <ActivityRow message={message} />; |
| 602 | return ( |
| 603 | <article className={`message-row role-${message.role}`}> |
| 604 | <div className="message-body"> |
| 605 | <ReactMarkdown remarkPlugins={[remarkGfm]} components={{a: (props) => <a {...props} target="_blank" rel="noreferrer" />}}> |
| 606 | {message.text} |
| 607 | </ReactMarkdown> |
| 608 | </div> |
| 609 | </article> |
| 610 | ); |
| 611 | } |
| 612 | |
| 613 | function ActivityRow({message}: {message: Message}) { |
| 614 | const stage = message.stage ? humanize(message.stage) : ''; |
| 615 | const detail = stage.toLowerCase() === message.text.toLowerCase() ? message.text : [stage, message.text].filter(Boolean).join(' · '); |
| 616 | const toolKind = activityToolKind(message.tool); |
| 617 | return ( |
| 618 | <div className={`activity-row status-${message.status || 'done'}`}> |
| 619 | <span className={`activity-indicator tool-${toolKind}`}><ActivityToolIcon tool={message.tool} /></span> |
| 620 | <div> |
| 621 | <strong>{humanize(message.tool || 'Workflow')}</strong> |
| 622 | <span>{detail}</span> |
| 623 | </div> |
| 624 | </div> |
| 625 | ); |
| 626 | } |
| 627 | |
| 628 | function ActivityToolIcon({tool}: {tool?: string}) { |
| 629 | const name = (tool || '').toLowerCase(); |
| 630 | const props = {size: 13, strokeWidth: 1.8}; |
| 631 | if (name.includes('narrative_planning') || name.includes('novel_planning')) return <FilePenLine {...props} />; |
| 632 | if (name.includes('render_video')) return <Film {...props} />; |
| 633 | if (name === 'view_image' || name.includes('image')) return <ImageIcon {...props} />; |
| 634 | if (name === 'read_json' || name === 'write_json') return <Braces {...props} />; |
| 635 | if (name === 'read_file' || name === 'write_file') return <FileText {...props} />; |
| 636 | if (name === 'list_files' || name === 'glob_files') return <Folder {...props} />; |
| 637 | if (name === 'search_text') return <Search {...props} />; |
| 638 | if (name.startsWith('memory_')) return <Brain {...props} />; |
| 639 | if (name.startsWith('todo_')) return <ListChecks {...props} />; |
| 640 | if (name === 'run_shell') return <Terminal {...props} />; |
| 641 | if (name === 'sleep') return <Clock3 {...props} />; |
| 642 | return <Wrench {...props} />; |
| 643 | } |
| 644 | |
| 645 | function activityToolKind(tool?: string) { |
| 646 | const name = (tool || '').toLowerCase(); |
| 647 | if (name.includes('narrative_planning') || name.includes('novel_planning')) return 'planning'; |
| 648 | if (name.includes('render_video')) return 'render'; |
| 649 | if (name === 'view_image' || name.includes('image')) return 'image'; |
| 650 | if (name.startsWith('memory_')) return 'memory'; |
| 651 | if (name.startsWith('todo_')) return 'todo'; |
| 652 | if (name === 'run_shell') return 'shell'; |
| 653 | if (name === 'sleep') return 'time'; |
| 654 | return 'file'; |
| 655 | } |
| 656 | |
| 657 | function ThinkingRow({messages}: {messages: Message[]}) { |
| 658 | const running = [...messages].reverse().find((message) => message.role === 'activity' && message.status === 'running'); |
| 659 | return ( |
| 660 | <div className="thinking-row"> |
| 661 | <span className="thinking-mark"><i /><i /><i /></span> |
| 662 | <span>{running ? `${humanize(running.tool || 'ViMax')} · ${running.text}` : 'ViMax is thinking'}</span> |
| 663 | </div> |
| 664 | ); |
| 665 | } |
| 666 | |
| 667 | function SlashCommandMenu({matches, contextPercent, onSelect}: {matches: SlashCommandMatch[]; contextPercent: number; onSelect: (command: string) => void}) { |
| 668 | return ( |
| 669 | <div className="slash-command-menu" role="listbox" aria-label="Slash commands"> |
| 670 | {matches.length > 0 ? matches.map((command) => ( |
| 671 | <button key={command.name} role="option" aria-selected="false" onMouseDown={(event) => event.preventDefault()} onClick={() => onSelect(command.name)}> |
| 672 | <code><span><b>{command.matchedPrefix}</b><span>{command.unmatchedSuffix}</span></span>{command.name === '/compact' && <em>{contextPercent}%</em>}</code> |
| 673 | <small>{command.description}</small> |
| 674 | </button> |
| 675 | )) : <span className="slash-command-empty">No matching commands</span>} |
| 676 | </div> |
| 677 | ); |
| 678 | } |
| 679 | |
| 680 | const CONFIG_SECTIONS: Array<{key: keyof AgentConfig['sections']; title: string; description: string}> = [ |
| 681 | {key: 'llm', title: 'Agent LLM', description: 'Planning, tool selection, and conversation'}, |
| 682 | {key: 'image', title: 'Image generation', description: 'Characters, keyframes, and shot frames'}, |
| 683 | {key: 'video', title: 'Video generation', description: 'Shot clips and final video'}, |
| 684 | {key: 'embedding', title: 'Embedding', description: 'Optional novel retrieval'}, |
| 685 | {key: 'reranker', title: 'Reranker', description: 'Optional novel retrieval ranking'}, |
| 686 | ]; |
| 687 | |
| 688 | function SettingsView() { |
| 689 | const [config, setConfig] = useState<AgentConfig>(); |
| 690 | const [loading, setLoading] = useState(true); |
| 691 | const [saving, setSaving] = useState(false); |
| 692 | const [status, setStatus] = useState(''); |
| 693 | |
| 694 | useEffect(() => { |
| 695 | let cancelled = false; |
| 696 | void getAgentConfig() |
| 697 | .then((payload) => !cancelled && setConfig(payload)) |
| 698 | .catch((error) => !cancelled && setStatus(error instanceof Error ? error.message : String(error))) |
| 699 | .finally(() => !cancelled && setLoading(false)); |
| 700 | return () => { cancelled = true; }; |
| 701 | }, []); |
| 702 | |
| 703 | function update(section: keyof AgentConfig['sections'], field: keyof ConfigSection, value: string) { |
| 704 | setStatus(''); |
| 705 | setConfig((current) => current ? { |
| 706 | sections: { |
| 707 | ...current.sections, |
| 708 | [section]: {...current.sections[section], [field]: value}, |
| 709 | }, |
| 710 | } : current); |
| 711 | } |
| 712 | |
| 713 | async function save() { |
| 714 | if (!config || saving) return; |
| 715 | setSaving(true); |
| 716 | setStatus(''); |
| 717 | try { |
| 718 | setConfig(await saveAgentConfig(config)); |
| 719 | setStatus('Saved'); |
| 720 | } catch (error) { |
| 721 | setStatus(error instanceof Error ? error.message : String(error)); |
| 722 | } finally { |
| 723 | setSaving(false); |
| 724 | } |
| 725 | } |
| 726 | |
| 727 | if (loading) return <div className="settings-loading">Loading configuration…</div>; |
| 728 | if (!config) return <div className="settings-loading is-error">{status || 'Configuration unavailable'}</div>; |
| 729 | return ( |
| 730 | <section className="settings-view"> |
| 731 | <header> |
| 732 | <div><span>Local configuration</span><h1>Settings</h1></div> |
| 733 | <div className="settings-save-group"> |
| 734 | {status && <span className={status === 'Saved' ? 'is-saved' : 'is-error'}>{status}</span>} |
| 735 | <button className="settings-save" onClick={() => void save()} disabled={saving}> |
| 736 | <Save size={15} />{saving ? 'Saving…' : 'Save'} |
| 737 | </button> |
| 738 | </div> |
| 739 | </header> |
| 740 | <div className="settings-sections"> |
| 741 | {CONFIG_SECTIONS.map((definition) => ( |
| 742 | <ConfigSectionEditor |
| 743 | key={definition.key} |
| 744 | definition={definition} |
| 745 | value={config.sections[definition.key]} |
| 746 | onChange={(field, value) => update(definition.key, field, value)} |
| 747 | /> |
| 748 | ))} |
| 749 | </div> |
| 750 | </section> |
| 751 | ); |
| 752 | } |
| 753 | |
| 754 | function ConfigSectionEditor({definition, value, onChange}: { |
| 755 | definition: {title: string; description: string}; |
| 756 | value: ConfigSection; |
| 757 | onChange: (field: keyof ConfigSection, value: string) => void; |
| 758 | }) { |
| 759 | return ( |
| 760 | <section className="config-section"> |
| 761 | <header><h2>{definition.title}</h2><p>{definition.description}</p></header> |
| 762 | <div className="config-fields"> |
| 763 | {value.model_provider !== undefined && ( |
| 764 | <label><span>Model provider</span><input value={value.model_provider} onChange={(event) => onChange('model_provider', event.target.value)} /></label> |
| 765 | )} |
| 766 | <label><span>Model</span><input value={value.model} onChange={(event) => onChange('model', event.target.value)} /></label> |
| 767 | <label className="config-field-wide"><span>Base URL</span><input value={value.base_url} onChange={(event) => onChange('base_url', event.target.value)} inputMode="url" /></label> |
| 768 | <label className="config-field-wide"> |
| 769 | <span>API key <i className={value.has_api_key ? 'is-configured' : ''}>{value.has_api_key ? 'Configured' : 'Not configured'}</i></span> |
| 770 | <input type="password" value={value.api_key} onChange={(event) => onChange('api_key', event.target.value)} placeholder={value.has_api_key ? 'Leave blank to keep current key' : 'Enter API key'} autoComplete="off" /> |
| 771 | </label> |
| 772 | </div> |
| 773 | </section> |
| 774 | ); |
| 775 | } |
| 776 | |
| 777 | function DeleteProjectDialog({session, deleting, onCancel, onConfirm}: { |
| 778 | session?: SessionSummary; |
| 779 | deleting: boolean; |
| 780 | onCancel: () => void; |
| 781 | onConfirm: () => void; |
| 782 | }) { |
| 783 | useEffect(() => { |
| 784 | if (!session) return; |
| 785 | const onKeyDown = (event: KeyboardEvent) => { |
| 786 | if (event.key === 'Escape' && !deleting) onCancel(); |
| 787 | }; |
| 788 | window.addEventListener('keydown', onKeyDown); |
| 789 | return () => window.removeEventListener('keydown', onKeyDown); |
| 790 | }, [deleting, onCancel, session]); |
| 791 | |
| 792 | if (!session) return null; |
| 793 | return ( |
| 794 | <div className="dialog-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && onCancel()}> |
| 795 | <section className="delete-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-project-title"> |
| 796 | <span className="dialog-icon"><Trash2 size={18} /></span> |
| 797 | <div className="dialog-copy"> |
| 798 | <h2 id="delete-project-title">Delete project?</h2> |
| 799 | <p><strong>{sessionTitle(session)}</strong> and its generated files will be permanently removed.</p> |
| 800 | </div> |
| 801 | <div className="dialog-actions"> |
| 802 | <button onClick={onCancel} disabled={deleting} autoFocus>Cancel</button> |
| 803 | <button className="danger" onClick={onConfirm} disabled={deleting}>{deleting ? 'Deleting…' : 'Delete'}</button> |
| 804 | </div> |
| 805 | </section> |
| 806 | </div> |
| 807 | ); |
| 808 | } |
| 809 | |
| 810 | function NewProjectDialog({open, name, error, creating, onNameChange, onCancel, onConfirm}: { |
| 811 | open: boolean; |
| 812 | name: string; |
| 813 | error: string; |
| 814 | creating: boolean; |
| 815 | onNameChange: (value: string) => void; |
| 816 | onCancel: () => void; |
| 817 | onConfirm: () => void; |
| 818 | }) { |
| 819 | useEffect(() => { |
| 820 | if (!open) return; |
| 821 | const onKeyDown = (event: KeyboardEvent) => { |
| 822 | if (event.key === 'Escape' && !creating) onCancel(); |
| 823 | }; |
| 824 | window.addEventListener('keydown', onKeyDown); |
| 825 | return () => window.removeEventListener('keydown', onKeyDown); |
| 826 | }, [creating, onCancel, open]); |
| 827 | |
| 828 | if (!open) return null; |
| 829 | return ( |
| 830 | <div className="dialog-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && onCancel()}> |
| 831 | <form className="project-dialog" role="dialog" aria-modal="true" aria-labelledby="new-project-title" onSubmit={(event) => { |
| 832 | event.preventDefault(); |
| 833 | onConfirm(); |
| 834 | }}> |
| 835 | <span className="dialog-icon is-create"><FolderPlus size={18} /></span> |
| 836 | <div className="dialog-copy"> |
| 837 | <h2 id="new-project-title">Create a new project</h2> |
| 838 | <p>Name the workspace before creating it.</p> |
| 839 | </div> |
| 840 | <label className="project-name-field"> |
| 841 | <span>Project name</span> |
| 842 | <input |
| 843 | value={name} |
| 844 | onChange={(event) => onNameChange(event.target.value)} |
| 845 | placeholder="Untitled video" |
| 846 | maxLength={64} |
| 847 | autoFocus |
| 848 | disabled={creating} |
| 849 | /> |
| 850 | {error && <small role="alert">{error}</small>} |
| 851 | </label> |
| 852 | <div className="dialog-actions"> |
| 853 | <button type="button" onClick={onCancel} disabled={creating}>Cancel</button> |
| 854 | <button type="submit" className="primary" disabled={creating || !name.trim()}>{creating ? 'Creating…' : 'Create'}</button> |
| 855 | </div> |
| 856 | </form> |
| 857 | </div> |
| 858 | ); |
| 859 | } |
| 860 | |
| 861 | function sessionTitle(session?: SessionSummary) { |
| 862 | if (!session) return 'New video'; |
| 863 | if (session.projectName) return session.projectName; |
| 864 | const source = session.idea || session.summary; |
| 865 | if (source) return source.length > 38 ? `${source.slice(0, 38).trim()}…` : source; |
| 866 | return session.sessionId.replace(/^\d{8}-\d{6}-?/, '') || 'Untitled video'; |
| 867 | } |
| 868 | |
| 869 | function stageLabel(stage: string) { |
| 870 | const labels: Record<string, string> = { |
| 871 | created: 'Created', |
| 872 | narrative_planning: 'Planning', |
| 873 | narrative_planned: 'Plan ready', |
| 874 | novel_planning: 'Planning novel', |
| 875 | novel_planned: 'Novel ready', |
| 876 | rendering: 'Rendering', |
| 877 | rendered: 'Rendered', |
| 878 | error: 'Needs attention', |
| 879 | }; |
| 880 | return labels[stage] || humanize(stage || 'Created'); |
| 881 | } |
| 882 | |
| 883 | function relativeTime(value: string) { |
| 884 | const timestamp = Date.parse(value); |
| 885 | if (!Number.isFinite(timestamp)) return 'Recently'; |
| 886 | const delta = Math.max(0, Date.now() - timestamp); |
| 887 | const minutes = Math.floor(delta / 60_000); |
| 888 | if (minutes < 1) return 'Now'; |
| 889 | if (minutes < 60) return `${minutes}m`; |
| 890 | const hours = Math.floor(minutes / 60); |
| 891 | if (hours < 24) return `${hours}h`; |
| 892 | return `${Math.floor(hours / 24)}d`; |
| 893 | } |
| 894 |