App.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. import { useCallback, useEffect, useState, useRef } from "react"
  2. import { useEventStream, useEventHandler, eventEmitter, type ServerEvent, type ConnectionState } from "./lib/api/events"
  3. import { useSessionEvents } from "./lib/api/useSessionEvents"
  4. import { useSession } from "./state/SessionContext"
  5. import { useMessages } from "./state/MessagesContext"
  6. import { useToast } from "./state/ToastContext"
  7. import { MessageInput } from "./components/MessageInput"
  8. import { MessageList } from "./components/MessageList"
  9. import { MessagesProvider } from "./state/MessagesContext"
  10. import { ThemeProvider } from "./state/ThemeContext"
  11. import { CompactHeader } from "./components/CompactHeader"
  12. import { FileChangesPanel } from "./components/FileChangesPanel"
  13. import { OfflineBanner } from "./components/OfflineBanner"
  14. import { CommandPalette } from "./components/CommandPalette"
  15. import { KeyboardShortcutsHelp } from "./components/KeyboardShortcutsHelp"
  16. import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"
  17. import { ideBridge } from "./lib/ideBridge"
  18. import { extractPathsFromDrop } from "./lib/dnd"
  19. const isMac = typeof navigator !== "undefined" && navigator.platform.includes("Mac")
  20. // Inner component that uses MessagesContext
  21. function AppInner({ connectionState }: { connectionState: ConnectionState }) {
  22. const { currentSession, sessions, newVirtual, switchSession, isCreating, error, clearError } = useSession()
  23. const { loadSessionMessages } = useMessages()
  24. const { showToast } = useToast()
  25. const compactHeaderRef = useRef<{ toggleSessionDropdown: () => void }>(null)
  26. const messageInputRef = useRef<{
  27. focus: () => void
  28. insertPaths: (paths: string[]) => void
  29. pastePath: (path: string) => void
  30. insertPlainWithMentions: (value: string) => void
  31. }>(null)
  32. // Keyboard shortcuts state
  33. const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false)
  34. const [isHelpOpen, setIsHelpOpen] = useState(false)
  35. const [isSettingsOpen, setIsSettingsOpen] = useState(false)
  36. const handleNewSession = useCallback(() => {
  37. newVirtual()
  38. }, [newVirtual])
  39. const handleToggleSessionList = useCallback(() => {
  40. compactHeaderRef.current?.toggleSessionDropdown()
  41. }, [])
  42. // Keyboard shortcuts handlers
  43. const handleCloseModal = useCallback(() => {
  44. if (isCommandPaletteOpen) {
  45. setIsCommandPaletteOpen(false)
  46. } else if (isHelpOpen) {
  47. setIsHelpOpen(false)
  48. } else if (isSettingsOpen) {
  49. setIsSettingsOpen(false)
  50. }
  51. }, [isCommandPaletteOpen, isHelpOpen, isSettingsOpen])
  52. const isAnyModalOpen = isCommandPaletteOpen || isHelpOpen || isSettingsOpen
  53. // Set up keyboard shortcuts
  54. useKeyboardShortcuts({
  55. onNewSession: handleNewSession,
  56. onOpenCommandPalette: () => setIsCommandPaletteOpen(true),
  57. onOpenSettings: () => setIsSettingsOpen(true),
  58. onShowHelp: () => setIsHelpOpen(true),
  59. onCloseModal: handleCloseModal,
  60. onToggleSessionList: handleToggleSessionList,
  61. isModalOpen: isAnyModalOpen,
  62. })
  63. // Host → UI bridge messages
  64. useEffect(() => {
  65. const handler = (msg: any) => {
  66. if (!msg || typeof msg !== "object") return
  67. if (msg.type === "insertPaths") {
  68. const paths = (msg.payload?.paths ?? msg.paths) as string[] | undefined
  69. if (Array.isArray(paths) && paths.length > 0) {
  70. messageInputRef.current?.focus()
  71. messageInputRef.current?.insertPaths(paths)
  72. }
  73. }
  74. if (msg.type === "pastePath") {
  75. const path = (msg.payload?.path ?? msg.path) as string | undefined
  76. if (typeof path === "string" && path.length > 0) {
  77. messageInputRef.current?.focus()
  78. messageInputRef.current?.pastePath(path)
  79. }
  80. }
  81. if (msg.type === "drag-event") {
  82. if (!isMac) return
  83. const eventType = typeof msg.eventType === "string" ? msg.eventType : ""
  84. const payload = msg.payload as
  85. | {
  86. clientX?: number
  87. clientY?: number
  88. shiftKey?: boolean
  89. dataTransfer?: { data?: Record<string, string> }
  90. }
  91. | undefined
  92. if (!eventType || !payload) return
  93. if (eventType === "drop" && payload.dataTransfer && payload.dataTransfer.data) {
  94. const uriList = payload.dataTransfer.data["application/vnd.code.uri-list"] as string | undefined
  95. if (!uriList) return
  96. const paths = uriList
  97. .split("\n")
  98. .map((s) => s.trim())
  99. .filter((s) => s.length > 0 && !s.startsWith("#"))
  100. .map((uri) => (uri.startsWith("file://") ? uri.replace("file://", "") : uri))
  101. if (paths.length === 0) return
  102. messageInputRef.current?.focus()
  103. messageInputRef.current?.insertPaths(paths)
  104. return
  105. }
  106. const clientX = typeof payload.clientX === "number" ? payload.clientX : 0
  107. const clientY = typeof payload.clientY === "number" ? payload.clientY : 0
  108. const shiftKey = !!payload.shiftKey
  109. const target = document.elementFromPoint(clientX, clientY) ?? document.body
  110. const synthetic = new DragEvent(eventType, {
  111. bubbles: true,
  112. cancelable: true,
  113. clientX,
  114. clientY,
  115. shiftKey,
  116. })
  117. target.dispatchEvent(synthetic)
  118. }
  119. }
  120. ideBridge.on(handler)
  121. return () => ideBridge.off(handler)
  122. }, [])
  123. // Accept drop anywhere in the webview (VSCode iframe)
  124. useEffect(() => {
  125. const onDragOver = (ev: DragEvent) => {
  126. ev.preventDefault()
  127. if (ev.dataTransfer) ev.dataTransfer.dropEffect = "copy"
  128. }
  129. const onDrop = (ev: DragEvent) => {
  130. ev.preventDefault()
  131. ev.stopPropagation()
  132. const paths = extractPathsFromDrop(ev)
  133. if (paths && paths.length > 0) {
  134. messageInputRef.current?.focus()
  135. messageInputRef.current?.insertPaths(paths)
  136. }
  137. }
  138. document.addEventListener("dragover", onDragOver as any)
  139. document.addEventListener("drop", onDrop as any)
  140. return () => {
  141. document.removeEventListener("dragover", onDragOver as any)
  142. document.removeEventListener("drop", onDrop as any)
  143. }
  144. }, [])
  145. // Load messages when session changes
  146. useEffect(() => {
  147. if (currentSession?.id) {
  148. console.log("[App] Current session changed, loading messages:", currentSession.id)
  149. loadSessionMessages(currentSession.id)
  150. // Focus message input when session changes
  151. setTimeout(() => {
  152. messageInputRef.current?.focus()
  153. }, 100)
  154. }
  155. }, [currentSession?.id, loadSessionMessages])
  156. // Show toast for session context errors
  157. useEffect(() => {
  158. if (error) {
  159. showToast(error.message, {
  160. title: "Error",
  161. variant: "error",
  162. duration: 8000,
  163. })
  164. // Clear error after showing toast
  165. clearError()
  166. }
  167. }, [error, showToast, clearError])
  168. return (
  169. <div className="flex flex-col h-screen bg-white dark:bg-gray-950">
  170. {/* Compact Header */}
  171. <CompactHeader
  172. ref={compactHeaderRef}
  173. connectionState={connectionState}
  174. onNewSession={handleNewSession}
  175. isCreatingSession={isCreating}
  176. onOpenCommandPalette={() => setIsCommandPaletteOpen(true)}
  177. />
  178. {/* Offline Banner */}
  179. <OfflineBanner connectionState={connectionState} />
  180. {/* Messages Area */}
  181. <main className="flex-1 overflow-y-auto px-4 py-3">
  182. <MessageList
  183. sessionID={currentSession?.id}
  184. onUndoToInput={(value) => messageInputRef.current?.insertPlainWithMentions(value)}
  185. />
  186. </main>
  187. {/* File Changes Panel (placeholder for now) */}
  188. <FileChangesPanel />
  189. {/* Input Area */}
  190. <MessageInput
  191. ref={messageInputRef}
  192. sessionID={currentSession?.id ?? null}
  193. onMessageSent={() => {
  194. console.log("[App] Message sent successfully")
  195. }}
  196. onError={(error) => {
  197. console.error("[App] Message send error:", error)
  198. }}
  199. />
  200. {/* Command Palette */}
  201. <CommandPalette
  202. isOpen={isCommandPaletteOpen}
  203. onClose={() => setIsCommandPaletteOpen(false)}
  204. sessions={sessions}
  205. onNewSession={handleNewSession}
  206. onSwitchSession={switchSession}
  207. onOpenSettings={() => setIsSettingsOpen(true)}
  208. onShowHelp={() => setIsHelpOpen(true)}
  209. />
  210. {/* Keyboard Shortcuts Help */}
  211. <KeyboardShortcutsHelp isOpen={isHelpOpen} onClose={() => setIsHelpOpen(false)} />
  212. </div>
  213. )
  214. }
  215. function AppContent() {
  216. const { connectionState, emitter } = useEventStream({
  217. debug: true,
  218. onConnectionStateChange: (state) => {
  219. console.log("[App] Connection state changed:", state)
  220. },
  221. })
  222. const { currentSession, setIsIdle } = useSession()
  223. const { showToast } = useToast()
  224. const handleAllEvents = useCallback(
  225. (event: ServerEvent) => {
  226. // Forward all events to the global singleton for SessionContext
  227. eventEmitter.emit(event)
  228. if (event.type === "server.connected") {
  229. console.log("[App] Successfully connected to OpenCode server")
  230. showToast("Connected to OpenCode server", { variant: "success", duration: 3000 })
  231. }
  232. // Handle session.idle events to show/hide typing indicator
  233. // Note: session.idle event only fires when session BECOMES idle (no idle boolean in payload)
  234. if (event.type === "session.idle") {
  235. const { sessionID } = event.properties
  236. console.log("[App] session.idle event:", { sessionID, currentSessionID: currentSession?.id })
  237. if (currentSession?.id === sessionID) {
  238. console.log("[App] Session became idle, setting isIdle to true")
  239. setIsIdle(true)
  240. } else {
  241. console.log("[App] Ignoring idle event for different session")
  242. }
  243. }
  244. // Handle session errors
  245. if (event.type === "session.error") {
  246. const { sessionID, error } = event.properties as { sessionID: string; error: unknown }
  247. if (currentSession?.id === sessionID) {
  248. const message = (() => {
  249. if (!error) return "An error occurred in the session"
  250. if (typeof error === "string") return error
  251. if (typeof error === "object") {
  252. const data = (error as { data?: { message?: unknown }; message?: unknown }).data
  253. const dataMessage = data && typeof data.message === "string" ? data.message : undefined
  254. if (dataMessage) return dataMessage
  255. const topMessage = (error as { message?: unknown }).message
  256. if (typeof topMessage === "string" && topMessage.length > 0) return topMessage
  257. }
  258. return "An error occurred in the session"
  259. })()
  260. console.error("[App] Session error:", message)
  261. showToast(message, {
  262. title: "Session Error",
  263. variant: "error",
  264. duration: 8000,
  265. })
  266. }
  267. }
  268. // Handle session compaction
  269. if (event.type === "session.compacted") {
  270. const { sessionID } = event.properties
  271. if (currentSession?.id === sessionID) {
  272. console.log("[App] Session compacted:", sessionID)
  273. showToast("Session history has been compacted to save space", {
  274. title: "Session Compacted",
  275. variant: "info",
  276. duration: 5000,
  277. })
  278. }
  279. }
  280. },
  281. [currentSession?.id, setIsIdle, showToast],
  282. )
  283. useEventHandler(emitter, "*", handleAllEvents)
  284. useSessionEvents(emitter, {
  285. onSessionCreated: (event) => {
  286. console.log("[App] Session created:", event.properties.sessionID)
  287. },
  288. onSessionUpdated: (event) => {
  289. console.log("[App] Session updated:", event.properties.sessionID)
  290. },
  291. onSessionDeleted: (event) => {
  292. console.log("[App] Session deleted:", event.properties.sessionID)
  293. },
  294. })
  295. return (
  296. <MessagesProvider emitter={emitter}>
  297. <AppInner connectionState={connectionState} />
  298. </MessagesProvider>
  299. )
  300. }
  301. function App() {
  302. return (
  303. <ThemeProvider>
  304. <AppContent />
  305. </ThemeProvider>
  306. )
  307. }
  308. export default App