import { useCallback, useEffect, useState, useRef } from "react" import { useEventStream, useEventHandler, eventEmitter, type ServerEvent, type ConnectionState } from "./lib/api/events" import { useSessionEvents } from "./lib/api/useSessionEvents" import { useSession } from "./state/SessionContext" import { useMessages } from "./state/MessagesContext" import { useToast } from "./state/ToastContext" import { MessageInput } from "./components/MessageInput" import { MessageList } from "./components/MessageList" import { MessagesProvider } from "./state/MessagesContext" import { ThemeProvider } from "./state/ThemeContext" import { CompactHeader } from "./components/CompactHeader" import { FileChangesPanel } from "./components/FileChangesPanel" import { OfflineBanner } from "./components/OfflineBanner" import { CommandPalette } from "./components/CommandPalette" import { KeyboardShortcutsHelp } from "./components/KeyboardShortcutsHelp" import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts" import { ideBridge } from "./lib/ideBridge" import { extractPathsFromDrop } from "./lib/dnd" const isMac = typeof navigator !== "undefined" && navigator.platform.includes("Mac") // Inner component that uses MessagesContext function AppInner({ connectionState }: { connectionState: ConnectionState }) { const { currentSession, sessions, newVirtual, switchSession, isCreating, error, clearError } = useSession() const { loadSessionMessages } = useMessages() const { showToast } = useToast() const compactHeaderRef = useRef<{ toggleSessionDropdown: () => void }>(null) const messageInputRef = useRef<{ focus: () => void insertPaths: (paths: string[]) => void pastePath: (path: string) => void insertPlainWithMentions: (value: string) => void }>(null) // Keyboard shortcuts state const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false) const [isHelpOpen, setIsHelpOpen] = useState(false) const [isSettingsOpen, setIsSettingsOpen] = useState(false) const handleNewSession = useCallback(() => { newVirtual() }, [newVirtual]) const handleToggleSessionList = useCallback(() => { compactHeaderRef.current?.toggleSessionDropdown() }, []) // Keyboard shortcuts handlers const handleCloseModal = useCallback(() => { if (isCommandPaletteOpen) { setIsCommandPaletteOpen(false) } else if (isHelpOpen) { setIsHelpOpen(false) } else if (isSettingsOpen) { setIsSettingsOpen(false) } }, [isCommandPaletteOpen, isHelpOpen, isSettingsOpen]) const isAnyModalOpen = isCommandPaletteOpen || isHelpOpen || isSettingsOpen // Set up keyboard shortcuts useKeyboardShortcuts({ onNewSession: handleNewSession, onOpenCommandPalette: () => setIsCommandPaletteOpen(true), onOpenSettings: () => setIsSettingsOpen(true), onShowHelp: () => setIsHelpOpen(true), onCloseModal: handleCloseModal, onToggleSessionList: handleToggleSessionList, isModalOpen: isAnyModalOpen, }) // Host → UI bridge messages useEffect(() => { const handler = (msg: any) => { if (!msg || typeof msg !== "object") return if (msg.type === "insertPaths") { const paths = (msg.payload?.paths ?? msg.paths) as string[] | undefined if (Array.isArray(paths) && paths.length > 0) { messageInputRef.current?.focus() messageInputRef.current?.insertPaths(paths) } } if (msg.type === "pastePath") { const path = (msg.payload?.path ?? msg.path) as string | undefined if (typeof path === "string" && path.length > 0) { messageInputRef.current?.focus() messageInputRef.current?.pastePath(path) } } if (msg.type === "drag-event") { if (!isMac) return const eventType = typeof msg.eventType === "string" ? msg.eventType : "" const payload = msg.payload as | { clientX?: number clientY?: number shiftKey?: boolean dataTransfer?: { data?: Record } } | undefined if (!eventType || !payload) return if (eventType === "drop" && payload.dataTransfer && payload.dataTransfer.data) { const uriList = payload.dataTransfer.data["application/vnd.code.uri-list"] as string | undefined if (!uriList) return const paths = uriList .split("\n") .map((s) => s.trim()) .filter((s) => s.length > 0 && !s.startsWith("#")) .map((uri) => (uri.startsWith("file://") ? uri.replace("file://", "") : uri)) if (paths.length === 0) return messageInputRef.current?.focus() messageInputRef.current?.insertPaths(paths) return } const clientX = typeof payload.clientX === "number" ? payload.clientX : 0 const clientY = typeof payload.clientY === "number" ? payload.clientY : 0 const shiftKey = !!payload.shiftKey const target = document.elementFromPoint(clientX, clientY) ?? document.body const synthetic = new DragEvent(eventType, { bubbles: true, cancelable: true, clientX, clientY, shiftKey, }) target.dispatchEvent(synthetic) } } ideBridge.on(handler) return () => ideBridge.off(handler) }, []) // Accept drop anywhere in the webview (VSCode iframe) useEffect(() => { const onDragOver = (ev: DragEvent) => { ev.preventDefault() if (ev.dataTransfer) ev.dataTransfer.dropEffect = "copy" } const onDrop = (ev: DragEvent) => { ev.preventDefault() ev.stopPropagation() const paths = extractPathsFromDrop(ev) if (paths && paths.length > 0) { messageInputRef.current?.focus() messageInputRef.current?.insertPaths(paths) } } document.addEventListener("dragover", onDragOver as any) document.addEventListener("drop", onDrop as any) return () => { document.removeEventListener("dragover", onDragOver as any) document.removeEventListener("drop", onDrop as any) } }, []) // Load messages when session changes useEffect(() => { if (currentSession?.id) { console.log("[App] Current session changed, loading messages:", currentSession.id) loadSessionMessages(currentSession.id) // Focus message input when session changes setTimeout(() => { messageInputRef.current?.focus() }, 100) } }, [currentSession?.id, loadSessionMessages]) // Show toast for session context errors useEffect(() => { if (error) { showToast(error.message, { title: "Error", variant: "error", duration: 8000, }) // Clear error after showing toast clearError() } }, [error, showToast, clearError]) return (
{/* Compact Header */} setIsCommandPaletteOpen(true)} /> {/* Offline Banner */} {/* Messages Area */}
messageInputRef.current?.insertPlainWithMentions(value)} />
{/* File Changes Panel (placeholder for now) */} {/* Input Area */} { console.log("[App] Message sent successfully") }} onError={(error) => { console.error("[App] Message send error:", error) }} /> {/* Command Palette */} setIsCommandPaletteOpen(false)} sessions={sessions} onNewSession={handleNewSession} onSwitchSession={switchSession} onOpenSettings={() => setIsSettingsOpen(true)} onShowHelp={() => setIsHelpOpen(true)} /> {/* Keyboard Shortcuts Help */} setIsHelpOpen(false)} />
) } function AppContent() { const { connectionState, emitter } = useEventStream({ debug: true, onConnectionStateChange: (state) => { console.log("[App] Connection state changed:", state) }, }) const { currentSession, setIsIdle } = useSession() const { showToast } = useToast() const handleAllEvents = useCallback( (event: ServerEvent) => { // Forward all events to the global singleton for SessionContext eventEmitter.emit(event) if (event.type === "server.connected") { console.log("[App] Successfully connected to OpenCode server") showToast("Connected to OpenCode server", { variant: "success", duration: 3000 }) } // Handle session.idle events to show/hide typing indicator // Note: session.idle event only fires when session BECOMES idle (no idle boolean in payload) if (event.type === "session.idle") { const { sessionID } = event.properties console.log("[App] session.idle event:", { sessionID, currentSessionID: currentSession?.id }) if (currentSession?.id === sessionID) { console.log("[App] Session became idle, setting isIdle to true") setIsIdle(true) } else { console.log("[App] Ignoring idle event for different session") } } // Handle session errors if (event.type === "session.error") { const { sessionID, error } = event.properties as { sessionID: string; error: unknown } if (currentSession?.id === sessionID) { const message = (() => { if (!error) return "An error occurred in the session" if (typeof error === "string") return error if (typeof error === "object") { const data = (error as { data?: { message?: unknown }; message?: unknown }).data const dataMessage = data && typeof data.message === "string" ? data.message : undefined if (dataMessage) return dataMessage const topMessage = (error as { message?: unknown }).message if (typeof topMessage === "string" && topMessage.length > 0) return topMessage } return "An error occurred in the session" })() console.error("[App] Session error:", message) showToast(message, { title: "Session Error", variant: "error", duration: 8000, }) } } // Handle session compaction if (event.type === "session.compacted") { const { sessionID } = event.properties if (currentSession?.id === sessionID) { console.log("[App] Session compacted:", sessionID) showToast("Session history has been compacted to save space", { title: "Session Compacted", variant: "info", duration: 5000, }) } } }, [currentSession?.id, setIsIdle, showToast], ) useEventHandler(emitter, "*", handleAllEvents) useSessionEvents(emitter, { onSessionCreated: (event) => { console.log("[App] Session created:", event.properties.sessionID) }, onSessionUpdated: (event) => { console.log("[App] Session updated:", event.properties.sessionID) }, onSessionDeleted: (event) => { console.log("[App] Session deleted:", event.properties.sessionID) }, }) return ( ) } function App() { return ( ) } export default App