| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342 |
- 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<string, string> }
- }
- | 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 (
- <div className="flex flex-col h-screen bg-white dark:bg-gray-950">
- {/* Compact Header */}
- <CompactHeader
- ref={compactHeaderRef}
- connectionState={connectionState}
- onNewSession={handleNewSession}
- isCreatingSession={isCreating}
- onOpenCommandPalette={() => setIsCommandPaletteOpen(true)}
- />
- {/* Offline Banner */}
- <OfflineBanner connectionState={connectionState} />
- {/* Messages Area */}
- <main className="flex-1 overflow-y-auto px-4 py-3">
- <MessageList
- sessionID={currentSession?.id}
- onUndoToInput={(value) => messageInputRef.current?.insertPlainWithMentions(value)}
- />
- </main>
- {/* File Changes Panel (placeholder for now) */}
- <FileChangesPanel />
- {/* Input Area */}
- <MessageInput
- ref={messageInputRef}
- sessionID={currentSession?.id ?? null}
- onMessageSent={() => {
- console.log("[App] Message sent successfully")
- }}
- onError={(error) => {
- console.error("[App] Message send error:", error)
- }}
- />
- {/* Command Palette */}
- <CommandPalette
- isOpen={isCommandPaletteOpen}
- onClose={() => setIsCommandPaletteOpen(false)}
- sessions={sessions}
- onNewSession={handleNewSession}
- onSwitchSession={switchSession}
- onOpenSettings={() => setIsSettingsOpen(true)}
- onShowHelp={() => setIsHelpOpen(true)}
- />
- {/* Keyboard Shortcuts Help */}
- <KeyboardShortcutsHelp isOpen={isHelpOpen} onClose={() => setIsHelpOpen(false)} />
- </div>
- )
- }
- 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 (
- <MessagesProvider emitter={emitter}>
- <AppInner connectionState={connectionState} />
- </MessagesProvider>
- )
- }
- function App() {
- return (
- <ThemeProvider>
- <AppContent />
- </ThemeProvider>
- )
- }
- export default App
|