SessionContext.tsx 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005
  1. import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react"
  2. import { sdk } from "../lib/api/sdkClient"
  3. import type { Session, FileDiff } from "@opencode-ai/sdk/client"
  4. import { eventEmitter } from "../lib/api/events"
  5. /**
  6. * Session context state
  7. */
  8. type SessionStatusInfo = {
  9. type: string
  10. attempt: number
  11. message: string
  12. next: number
  13. }
  14. interface SessionContextState {
  15. // Current active session
  16. currentSession: Session | null
  17. setCurrentSession: (session: Session | null) => void
  18. // All sessions
  19. sessions: Session[]
  20. setSessions: (sessions: Session[]) => void
  21. // Loading and error states
  22. isCreating: boolean
  23. isLoading: boolean
  24. error: Error | null
  25. // Idle state (false = session is running/generating, true = idle)
  26. isIdle: boolean
  27. setIsIdle: (isIdle: boolean) => void
  28. // Reasoning state per session
  29. isReasoning: boolean
  30. setReasoning: (sessionId: string, active: boolean) => void
  31. // Session diff data (per session)
  32. sessionDiff: Record<string, FileDiff[]>
  33. // Session status for current session
  34. currentStatus: SessionStatusInfo
  35. // Model and Agent selection
  36. selectedProviderId: string | undefined
  37. selectedModelId: string | undefined
  38. selectedAgent: string
  39. setSelectedModel: (providerId: string | undefined, modelId: string | undefined) => Promise<void>
  40. setSelectedAgent: (agent: string) => Promise<void>
  41. // Variant selection (per provider/model combo)
  42. selectedVariant: string | undefined
  43. setSelectedVariant: (variant: string | undefined) => Promise<void>
  44. // Virtual session tracking
  45. isVirtualSession: boolean
  46. // Actions
  47. newVirtual: () => Session
  48. createSession: (options?: { title?: string }) => Promise<Session | null>
  49. materializeSession: () => Promise<Session | null>
  50. loadSessions: () => Promise<void>
  51. switchSession: (sessionId: string) => Promise<void>
  52. updateSessionTitle: (sessionId: string, title: string) => Promise<boolean>
  53. deleteSession: (sessionId: string) => Promise<boolean>
  54. forkSession: (sessionId: string, messageId: string) => Promise<Session | null>
  55. revertToMessage: (sessionId: string, messageId: string, partId?: string) => Promise<Session | null>
  56. unrevertSession: (sessionId: string) => Promise<Session | null>
  57. redoNext: (sessionId: string) => Promise<Session | null>
  58. retrySession: (sessionId: string) => Promise<void>
  59. clearError: () => void
  60. }
  61. const SessionContext = createContext<SessionContextState | null>(null)
  62. /**
  63. * Hook to access session context
  64. *
  65. * @throws Error if used outside SessionProvider
  66. */
  67. export function useSession() {
  68. const context = useContext(SessionContext)
  69. if (!context) {
  70. throw new Error("useSession must be used within a SessionProvider")
  71. }
  72. return context
  73. }
  74. interface SessionProviderProps {
  75. children: ReactNode
  76. }
  77. /**
  78. * Helper function to create a virtual session object
  79. */
  80. function createVirtualSession(): Session {
  81. const now = Date.now()
  82. return {
  83. id: `virtual-${now}`,
  84. title: "",
  85. time: {
  86. created: now,
  87. updated: now,
  88. },
  89. } as Session
  90. }
  91. /**
  92. * Check if a session title is a default auto-generated title
  93. * Matches pattern: "New session - 2025-10-31T11:44:37.671Z" or "Child session - ..."
  94. */
  95. export function isDefaultTitle(title: string): boolean {
  96. return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
  97. }
  98. /**
  99. * Session provider component
  100. *
  101. * Manages the current active session state and provides session-related actions.
  102. */
  103. export function SessionProvider({ children }: SessionProviderProps) {
  104. const [currentSession, setCurrentSession] = useState<Session | null>(() => createVirtualSession())
  105. const [isVirtualSession, setIsVirtualSession] = useState(true)
  106. const [sessions, setSessions] = useState<Session[]>([])
  107. const [isCreating, setIsCreating] = useState(false)
  108. const [isLoading, setIsLoading] = useState(false)
  109. const [error, setError] = useState<Error | null>(null)
  110. const [isIdle, setIsIdle] = useState(true)
  111. const [reasoningMap, setReasoningMap] = useState<Record<string, boolean>>({})
  112. const [statusMap, setStatusMap] = useState<Record<string, SessionStatusInfo>>({})
  113. const [sessionDiffMap, setSessionDiffMap] = useState<Record<string, FileDiff[]>>({})
  114. // Model and Agent selection state (synced with server state + localStorage fallback)
  115. const [selectedProviderId, setSelectedProviderId] = useState<string | undefined>()
  116. const [selectedModelId, setSelectedModelId] = useState<string | undefined>()
  117. const [selectedAgent, setSelectedAgentState] = useState<string>("build")
  118. const [agentModelMap, setAgentModelMap] = useState<Record<string, { provider_id: string; model_id: string }>>({})
  119. // Variant selection state (per provider/model combo, key = "providerId/modelId")
  120. const [selectedVariant, setSelectedVariantState] = useState<string | undefined>()
  121. const [variantMap, setVariantMap] = useState<Record<string, string>>({})
  122. const isReasoning = currentSession?.id ? Boolean(reasoningMap[currentSession.id]) : false
  123. const currentStatus: SessionStatusInfo =
  124. currentSession?.id && statusMap[currentSession.id]
  125. ? statusMap[currentSession.id]
  126. : { type: "idle", attempt: 0, message: "", next: Date.now() }
  127. const setReasoning = useCallback((sessionId: string, active: boolean) => {
  128. if (!sessionId) return
  129. setReasoningMap((prev) => {
  130. const current = prev[sessionId] ?? false
  131. if (current === active) return prev
  132. if (active) {
  133. return { ...prev, [sessionId]: true }
  134. }
  135. const next = { ...prev }
  136. delete next[sessionId]
  137. return next
  138. })
  139. }, [])
  140. /**
  141. * Initialize state from server on mount
  142. * Priority: server state > config > localStorage
  143. */
  144. useEffect(() => {
  145. const initializeState = async () => {
  146. try {
  147. // Fetch state from server
  148. const stateResponse = await sdk.state.get()
  149. if (stateResponse.data) {
  150. const state = stateResponse.data
  151. // Cache agent_model map
  152. if (state.agent_model) {
  153. setAgentModelMap(state.agent_model)
  154. }
  155. // Load variant map from server state
  156. if (state.variant) {
  157. setVariantMap(state.variant)
  158. }
  159. // Set agent (default to 'build' if not set)
  160. const agent = state.agent || "build"
  161. setSelectedAgentState(agent)
  162. localStorage.setItem("opencode_selected_agent", agent)
  163. // Check if there's a per-agent model preference
  164. let providerId = state.provider
  165. let modelId = state.model
  166. if (state.agent_model && state.agent_model[agent]) {
  167. // Use per-agent model preference
  168. providerId = state.agent_model[agent].provider_id
  169. modelId = state.agent_model[agent].model_id
  170. }
  171. // If no state, try config fallback
  172. if (!providerId || !modelId) {
  173. const configResponse = await sdk.config.get()
  174. if (configResponse.data?.model) {
  175. // Parse "provider/model" format
  176. const parts = configResponse.data.model.split("/")
  177. if (parts.length === 2) {
  178. providerId = parts[0]
  179. modelId = parts[1]
  180. }
  181. }
  182. }
  183. // Set model/provider if we have them
  184. if (providerId && modelId) {
  185. setSelectedProviderId(providerId)
  186. setSelectedModelId(modelId)
  187. localStorage.setItem("opencode_selected_provider", providerId)
  188. localStorage.setItem("opencode_selected_model", modelId)
  189. // Compute initial variant for the selected model
  190. if (state.variant) {
  191. const modelKey = `${providerId}/${modelId}`
  192. setSelectedVariantState(state.variant[modelKey])
  193. }
  194. }
  195. }
  196. } catch (err) {
  197. console.error("[SessionContext] Failed to load state from server, using localStorage fallback:", err)
  198. // Fallback to localStorage if server state fails
  199. const savedProvider = localStorage.getItem("opencode_selected_provider")
  200. const savedModel = localStorage.getItem("opencode_selected_model")
  201. const savedAgent = localStorage.getItem("opencode_selected_agent")
  202. if (savedProvider) setSelectedProviderId(savedProvider)
  203. if (savedModel) setSelectedModelId(savedModel)
  204. if (savedAgent) setSelectedAgentState(savedAgent)
  205. }
  206. }
  207. initializeState()
  208. }, [])
  209. /**
  210. * Set selected model and persist to server + localStorage
  211. * Also updates per-agent model preference
  212. */
  213. const setSelectedModel = useCallback(
  214. async (providerId: string | undefined, modelId: string | undefined) => {
  215. setSelectedProviderId(providerId)
  216. setSelectedModelId(modelId)
  217. // Restore variant for the new model
  218. if (providerId && modelId) {
  219. const modelKey = `${providerId}/${modelId}`
  220. setSelectedVariantState(variantMap[modelKey])
  221. } else {
  222. setSelectedVariantState(undefined)
  223. }
  224. // Persist to localStorage as fallback
  225. if (providerId) {
  226. localStorage.setItem("opencode_selected_provider", providerId)
  227. } else {
  228. localStorage.removeItem("opencode_selected_provider")
  229. }
  230. if (modelId) {
  231. localStorage.setItem("opencode_selected_model", modelId)
  232. } else {
  233. localStorage.removeItem("opencode_selected_model")
  234. }
  235. // Persist to server state (including per-agent preference)
  236. if (providerId && modelId) {
  237. try {
  238. // Update cached agent_model map
  239. const currentAgent = selectedAgent
  240. const updatedAgentModel = currentAgent
  241. ? {
  242. ...agentModelMap,
  243. [currentAgent]: {
  244. provider_id: providerId,
  245. model_id: modelId,
  246. },
  247. }
  248. : agentModelMap
  249. setAgentModelMap(updatedAgentModel)
  250. const stateResponse = await sdk.state.get()
  251. const existingRecent = stateResponse.data?.recently_used_models ?? []
  252. const now = new Date().toISOString()
  253. const filtered = existingRecent.filter(
  254. (item) => !(item.provider_id === providerId && item.model_id === modelId),
  255. )
  256. const nextRecent = [{ provider_id: providerId, model_id: modelId, last_used: now }, ...filtered].slice(0, 2)
  257. // Update server state
  258. await sdk.state.update({
  259. body: {
  260. provider: providerId,
  261. model: modelId,
  262. agent_model: updatedAgentModel,
  263. recently_used_models: nextRecent,
  264. },
  265. })
  266. } catch (err) {
  267. console.error("[SessionContext] Failed to save model preference to server:", err)
  268. }
  269. }
  270. },
  271. [selectedAgent, agentModelMap, variantMap],
  272. )
  273. /**
  274. * Set selected variant and persist to server
  275. * Updates per-model variant preference
  276. */
  277. const setSelectedVariant = useCallback(
  278. async (variant: string | undefined) => {
  279. setSelectedVariantState(variant)
  280. // Get current model key
  281. if (selectedProviderId && selectedModelId) {
  282. const modelKey = `${selectedProviderId}/${selectedModelId}`
  283. // Update variant map
  284. let updatedVariantMap = { ...variantMap }
  285. if (variant) {
  286. updatedVariantMap[modelKey] = variant
  287. } else {
  288. delete updatedVariantMap[modelKey]
  289. }
  290. setVariantMap(updatedVariantMap)
  291. // Persist to server
  292. try {
  293. await sdk.state.update({
  294. body: {
  295. variant: updatedVariantMap,
  296. },
  297. })
  298. } catch (err) {
  299. console.error("[SessionContext] Failed to save variant preference:", err)
  300. }
  301. }
  302. },
  303. [selectedProviderId, selectedModelId, variantMap],
  304. )
  305. /**
  306. * Set selected agent and persist to server + localStorage
  307. * Also handles per-agent model preferences
  308. */
  309. const setSelectedAgent = useCallback(
  310. async (newAgent: string) => {
  311. try {
  312. // Fetch current state to get agent_model map
  313. const stateResponse = await sdk.state.get()
  314. const currentAgent = selectedAgent
  315. const currentProvider = selectedProviderId
  316. const currentModel = selectedModelId
  317. // Save current model for current agent if we have one
  318. let agentModel = stateResponse.data?.agent_model || {}
  319. if (currentAgent && currentProvider && currentModel) {
  320. agentModel = {
  321. ...agentModel,
  322. [currentAgent]: {
  323. provider_id: currentProvider,
  324. model_id: currentModel,
  325. },
  326. }
  327. console.log(`[SessionContext] Saved model for agent ${currentAgent}:`, currentProvider, currentModel)
  328. }
  329. // Check if new agent has a saved model preference
  330. let newProvider = currentProvider
  331. let newModel = currentModel
  332. if (agentModel[newAgent]) {
  333. newProvider = agentModel[newAgent].provider_id
  334. newModel = agentModel[newAgent].model_id
  335. console.log(`[SessionContext] Restoring model for agent ${newAgent}:`, newProvider, newModel)
  336. }
  337. // Update state
  338. setSelectedAgentState(newAgent)
  339. localStorage.setItem("opencode_selected_agent", newAgent)
  340. // Update model if it changed
  341. if (newProvider !== currentProvider || newModel !== currentModel) {
  342. setSelectedProviderId(newProvider)
  343. setSelectedModelId(newModel)
  344. if (newProvider) localStorage.setItem("opencode_selected_provider", newProvider)
  345. if (newModel) localStorage.setItem("opencode_selected_model", newModel)
  346. }
  347. // Persist to server (save both agent and agent_model map)
  348. await sdk.state.update({
  349. body: {
  350. agent: newAgent,
  351. agent_model: agentModel,
  352. provider: newProvider,
  353. model: newModel,
  354. },
  355. })
  356. console.log("[SessionContext] Agent and model preferences saved to server")
  357. } catch (err) {
  358. console.error("[SessionContext] Failed to save agent preference to server:", err)
  359. // Fallback to simple update
  360. setSelectedAgentState(newAgent)
  361. localStorage.setItem("opencode_selected_agent", newAgent)
  362. }
  363. },
  364. [selectedAgent, selectedProviderId, selectedModelId],
  365. )
  366. /**
  367. * Load all sessions
  368. */
  369. const loadSessions = useCallback(async () => {
  370. setIsLoading(true)
  371. setError(null)
  372. console.log("[SessionContext] Loading sessions...")
  373. try {
  374. const response = await sdk.session.list()
  375. if (response.error) {
  376. const errorData =
  377. response.error && typeof response.error === "object" && "data" in response.error ? response.error.data : null
  378. const errorMsg =
  379. errorData && typeof errorData === "object" && errorData !== null && "message" in errorData
  380. ? String(errorData.message)
  381. : "Failed to load sessions"
  382. console.error("[SessionContext] Failed to load sessions:", errorMsg)
  383. setError(new Error(errorMsg))
  384. setIsLoading(false)
  385. return
  386. }
  387. if (response.data) {
  388. console.log("[SessionContext] Sessions loaded:", response.data.length)
  389. // Sort by creation time (newest first)
  390. const sorted = [...response.data].sort((a, b) => b.time.created - a.time.created)
  391. setSessions(sorted)
  392. setIsLoading(false)
  393. return
  394. }
  395. setIsLoading(false)
  396. } catch (err) {
  397. const errorMsg = err instanceof Error ? err.message : "Failed to load sessions"
  398. console.error("[SessionContext] Failed to load sessions:", errorMsg)
  399. setError(new Error(errorMsg))
  400. setIsLoading(false)
  401. }
  402. }, [])
  403. /**
  404. * Create a new session
  405. */
  406. const createSession = useCallback(async (options?: { title?: string }) => {
  407. setIsCreating(true)
  408. setError(null)
  409. console.log("[SessionContext] Creating new session...", options)
  410. try {
  411. const response = await sdk.session.create({
  412. body: options,
  413. })
  414. if (response.error) {
  415. const errorMsg =
  416. "data" in response.error &&
  417. response.error.data &&
  418. typeof response.error.data === "object" &&
  419. "message" in response.error.data
  420. ? String(response.error.data.message)
  421. : "Failed to create session"
  422. console.error("[SessionContext] Failed to create session:", errorMsg)
  423. setError(new Error(errorMsg))
  424. setIsCreating(false)
  425. return null
  426. }
  427. if (response.data) {
  428. console.log("[SessionContext] Session created:", response.data.id)
  429. setCurrentSession(response.data)
  430. setIsVirtualSession(false)
  431. // Don't add to sessions list here - let the session.created event handler do it
  432. // This prevents duplicate sessions in the list
  433. setIsCreating(false)
  434. return response.data
  435. }
  436. setError(new Error("No session data returned"))
  437. setIsCreating(false)
  438. return null
  439. } catch (err) {
  440. const errorMsg = err instanceof Error ? err.message : "Failed to create session"
  441. console.error("[SessionContext] Failed to create session:", errorMsg)
  442. setError(new Error(errorMsg))
  443. setIsCreating(false)
  444. return null
  445. }
  446. }, [])
  447. /**
  448. * Start a new virtual session (not persisted until first message)
  449. */
  450. const newVirtual = useCallback(() => {
  451. const v = createVirtualSession()
  452. setCurrentSession(v)
  453. setIsVirtualSession(true)
  454. return v
  455. }, [])
  456. /**
  457. * Materialize a virtual session into a real session
  458. * This is called when the user sends their first message
  459. */
  460. const materializeSession = useCallback(async () => {
  461. if (!isVirtualSession) {
  462. console.log("[SessionContext] Session is already materialized")
  463. return currentSession
  464. }
  465. console.log("[SessionContext] Materializing virtual session...")
  466. // Create a real session
  467. const realSession = await createSession()
  468. if (realSession) {
  469. console.log("[SessionContext] Virtual session materialized:", realSession.id)
  470. return realSession
  471. }
  472. console.error("[SessionContext] Failed to materialize virtual session")
  473. return null
  474. }, [isVirtualSession, currentSession, createSession])
  475. /**
  476. * Switch to a different session
  477. */
  478. const switchSession = useCallback(
  479. async (sessionId: string) => {
  480. console.log("[SessionContext] Switching to session:", sessionId)
  481. const session = sessions.find((s) => s.id === sessionId)
  482. if (session) {
  483. setCurrentSession(session)
  484. setIsVirtualSession(false)
  485. } else {
  486. console.warn("[SessionContext] Session not found in local list, fetching...")
  487. // If not in local list, fetch it
  488. const response = await sdk.session.get({ path: { id: sessionId } })
  489. if (response.data) {
  490. setCurrentSession(response.data)
  491. setIsVirtualSession(false)
  492. }
  493. }
  494. },
  495. [sessions],
  496. )
  497. /**
  498. * Update session title
  499. */
  500. const updateSessionTitle = useCallback(
  501. async (sessionId: string, title: string) => {
  502. console.log("[SessionContext] Updating session title:", sessionId, title)
  503. try {
  504. const response = await sdk.session.update({
  505. path: { id: sessionId },
  506. body: { title },
  507. })
  508. if (response.error) {
  509. const errorMsg =
  510. "data" in response.error &&
  511. response.error.data &&
  512. typeof response.error.data === "object" &&
  513. "message" in response.error.data
  514. ? String(response.error.data.message)
  515. : "Failed to update session"
  516. console.error("[SessionContext] Failed to update session:", errorMsg)
  517. setError(new Error(errorMsg))
  518. return false
  519. }
  520. if (response.data) {
  521. console.log("[SessionContext] Session updated:", response.data.id)
  522. // Update in local state
  523. setSessions((prev) => prev.map((s) => (s.id === sessionId ? response.data! : s)))
  524. if (currentSession?.id === sessionId) {
  525. setCurrentSession(response.data)
  526. }
  527. return true
  528. }
  529. return false
  530. } catch (err) {
  531. const errorMsg = err instanceof Error ? err.message : "Failed to update session"
  532. console.error("[SessionContext] Failed to update session:", errorMsg)
  533. setError(new Error(errorMsg))
  534. return false
  535. }
  536. },
  537. [currentSession],
  538. )
  539. /**
  540. * Delete a session
  541. */
  542. const deleteSession = useCallback(
  543. async (sessionId: string) => {
  544. console.log("[SessionContext] Deleting session:", sessionId)
  545. try {
  546. const response = await sdk.session.delete({
  547. path: { id: sessionId },
  548. })
  549. if (response.error) {
  550. const errorMsg =
  551. "data" in response.error &&
  552. response.error.data &&
  553. typeof response.error.data === "object" &&
  554. "message" in response.error.data
  555. ? String(response.error.data.message)
  556. : "Failed to delete session"
  557. console.error("[SessionContext] Failed to delete session:", errorMsg)
  558. setError(new Error(errorMsg))
  559. return false
  560. }
  561. console.log("[SessionContext] Session deleted:", sessionId)
  562. // Remove from local state
  563. setSessions((prev) => prev.filter((s) => s.id !== sessionId))
  564. setReasoning(sessionId, false)
  565. // If deleting current session, switch to another
  566. if (currentSession?.id === sessionId) {
  567. newVirtual()
  568. }
  569. return true
  570. } catch (err) {
  571. const errorMsg = err instanceof Error ? err.message : "Failed to delete session"
  572. console.error("[SessionContext] Failed to delete session:", errorMsg)
  573. setError(new Error(errorMsg))
  574. return false
  575. }
  576. },
  577. [currentSession, sessions, setReasoning, newVirtual],
  578. )
  579. /**
  580. * Fork a session at a specific message
  581. */
  582. const forkSession = useCallback(async (sessionId: string, messageId: string) => {
  583. console.log("[SessionContext] Forking session:", sessionId, "at message:", messageId)
  584. try {
  585. const response = await sdk.session.fork({
  586. path: { id: sessionId },
  587. body: { messageID: messageId },
  588. })
  589. if (response.error) {
  590. const errorData =
  591. response.error && typeof response.error === "object" && "data" in response.error ? response.error.data : null
  592. const errorMsg =
  593. errorData && typeof errorData === "object" && errorData !== null && "message" in errorData
  594. ? String(errorData.message)
  595. : "Failed to fork session"
  596. console.error("[SessionContext] Failed to fork session:", errorMsg)
  597. setError(new Error(errorMsg))
  598. return null
  599. }
  600. if (response.data) {
  601. console.log("[SessionContext] Session forked:", response.data.id)
  602. // Don't add to sessions list here - let the session.created event handler do it
  603. // This prevents duplicate sessions in the list
  604. // Switch to forked session
  605. setCurrentSession(response.data)
  606. setIsVirtualSession(false)
  607. return response.data
  608. }
  609. return null
  610. } catch (err) {
  611. const errorMsg = err instanceof Error ? err.message : "Failed to fork session"
  612. console.error("[SessionContext] Failed to fork session:", errorMsg)
  613. setError(new Error(errorMsg))
  614. return null
  615. }
  616. }, [])
  617. /**
  618. * Revert (undo) to a specific message/part
  619. */
  620. const revertToMessage = useCallback(
  621. async (sessionId: string, messageId: string, partId?: string) => {
  622. console.log(
  623. "[SessionContext] Reverting session:",
  624. sessionId,
  625. "to message:",
  626. messageId,
  627. partId ? `(part: ${partId})` : "",
  628. )
  629. try {
  630. const response = await sdk.session.revert({
  631. path: { id: sessionId },
  632. body: { messageID: messageId, ...(partId ? { partID: partId } : {}) },
  633. })
  634. if (response.error) {
  635. const errorData =
  636. response.error && typeof response.error === "object" && "data" in response.error
  637. ? (response.error as any).data
  638. : null
  639. const errorMsg =
  640. errorData && typeof errorData === "object" && errorData !== null && "message" in errorData
  641. ? String((errorData as any).message)
  642. : "Failed to revert session"
  643. console.error("[SessionContext] Failed to revert session:", errorMsg)
  644. setError(new Error(errorMsg))
  645. return null
  646. }
  647. if (response.data) {
  648. if (currentSession?.id === sessionId) setCurrentSession(response.data)
  649. return response.data
  650. }
  651. return null
  652. } catch (err) {
  653. const errorMsg = err instanceof Error ? err.message : "Failed to revert session"
  654. console.error("[SessionContext] Failed to revert session:", errorMsg)
  655. setError(new Error(errorMsg))
  656. return null
  657. }
  658. },
  659. [currentSession],
  660. )
  661. /**
  662. * Restore all reverted messages (unrevert)
  663. */
  664. const unrevertSession = useCallback(
  665. async (sessionId: string) => {
  666. console.log("[SessionContext] Unreverting session:", sessionId)
  667. try {
  668. const response = await sdk.session.unrevert({ path: { id: sessionId } })
  669. if (response.error) {
  670. const errorData =
  671. response.error && typeof response.error === "object" && "data" in response.error
  672. ? (response.error as any).data
  673. : null
  674. const errorMsg =
  675. errorData && typeof errorData === "object" && errorData !== null && "message" in errorData
  676. ? String((errorData as any).message)
  677. : "Failed to restore messages"
  678. console.error("[SessionContext] Failed to unrevert session:", errorMsg)
  679. setError(new Error(errorMsg))
  680. return null
  681. }
  682. if (response.data) {
  683. if (currentSession?.id === sessionId) setCurrentSession(response.data)
  684. return response.data
  685. }
  686. return null
  687. } catch (err) {
  688. const errorMsg = err instanceof Error ? err.message : "Failed to restore messages"
  689. console.error("[SessionContext] Failed to unrevert session:", errorMsg)
  690. setError(new Error(errorMsg))
  691. return null
  692. }
  693. },
  694. [currentSession],
  695. )
  696. /**
  697. * Redo one step forward from current revert boundary.
  698. * If there is no next user message, fall back to unrevert (restore all).
  699. */
  700. const redoNext = useCallback(
  701. async (sessionId: string) => {
  702. try {
  703. const resp = await sdk.session.messages({ path: { id: sessionId } })
  704. const list = resp.data ?? []
  705. const session = currentSession?.id === sessionId ? currentSession : null
  706. const boundary = session?.revert?.messageID
  707. if (!boundary) return null
  708. const sorted = [...list].sort((a, b) => a.info.time.created - b.info.time.created)
  709. let target: string | null = null
  710. let seenBoundary = false
  711. for (const m of sorted) {
  712. if (m.info.id === boundary) {
  713. seenBoundary = true
  714. continue
  715. }
  716. if (!seenBoundary) continue
  717. if (m.info.role === "user") {
  718. target = m.info.id
  719. break
  720. }
  721. }
  722. if (!target) return await unrevertSession(sessionId)
  723. return await revertToMessage(sessionId, target)
  724. } catch (e) {
  725. return null
  726. }
  727. },
  728. [currentSession, revertToMessage, unrevertSession],
  729. )
  730. /**
  731. * Retry a session's execution
  732. */
  733. const retrySession = useCallback(async (sessionId: string) => {
  734. console.log("[SessionContext] Retrying session:", sessionId)
  735. setIsIdle(false)
  736. try {
  737. await sdk.session.retry({ path: { sessionID: sessionId } })
  738. } catch (err) {
  739. const errorMsg = err instanceof Error ? err.message : "Failed to retry session"
  740. console.error("[SessionContext] Failed to retry session:", errorMsg)
  741. setError(new Error(errorMsg))
  742. setIsIdle(true)
  743. }
  744. }, [])
  745. /**
  746. * Clear the current error
  747. */
  748. const clearError = useCallback(() => {
  749. setError(null)
  750. }, [])
  751. // Load sessions on mount
  752. useEffect(() => {
  753. loadSessions()
  754. }, [loadSessions])
  755. // Load session diff when current session changes
  756. useEffect(() => {
  757. const sessionId = currentSession?.id
  758. if (!sessionId || isVirtualSession) return
  759. const controller = new AbortController()
  760. const fetchDiff = async () => {
  761. try {
  762. const response = await sdk.session.diff({ path: { id: sessionId } })
  763. if (controller.signal.aborted) return
  764. if (response.data) {
  765. setSessionDiffMap((prev) => ({ ...prev, [sessionId]: response.data }))
  766. }
  767. } catch (err) {
  768. if (!controller.signal.aborted) {
  769. console.error("[SessionContext] Failed to load session diff:", err)
  770. }
  771. }
  772. }
  773. fetchDiff()
  774. return () => controller.abort()
  775. }, [currentSession?.id, isVirtualSession])
  776. // Listen for session events from SSE
  777. useEffect(() => {
  778. const handleSessionCreated = (event: any) => {
  779. if (event.type === "session.created" && event.properties?.info) {
  780. console.log("[SessionContext] Session created event:", event.properties.info.id)
  781. setSessions((prev) => {
  782. // Check if already exists
  783. if (prev.some((s) => s.id === event.properties.info.id)) {
  784. return prev
  785. }
  786. return [event.properties.info, ...prev]
  787. })
  788. }
  789. }
  790. const handleSessionUpdated = (event: any) => {
  791. if (event.type === "session.updated" && event.properties?.info) {
  792. const updatedSession = event.properties.info
  793. console.log("[SessionContext] Session updated event:", updatedSession.id, {
  794. title: updatedSession.title,
  795. updated: new Date(updatedSession.time.updated).toISOString(),
  796. isDefaultTitle: isDefaultTitle(updatedSession.title),
  797. })
  798. // Check if title changed (useful for debugging auto-title generation)
  799. const existingSession = sessions.find((s) => s.id === updatedSession.id)
  800. if (existingSession && existingSession.title !== updatedSession.title) {
  801. console.log("[SessionContext] 🎉 Session title CHANGED:", existingSession.title, "→", updatedSession.title)
  802. }
  803. setSessions((prev) => prev.map((s) => (s.id === updatedSession.id ? updatedSession : s)))
  804. if (currentSession?.id === updatedSession.id) {
  805. setCurrentSession(updatedSession)
  806. }
  807. }
  808. }
  809. const handleSessionDeleted = (event: any) => {
  810. if (event.type === "session.deleted" && event.properties?.info) {
  811. const deletedId = event.properties.info.id
  812. console.log("[SessionContext] Session deleted event:", deletedId)
  813. setSessions((prev) => prev.filter((s) => s.id !== deletedId))
  814. setSessionDiffMap((prev) => {
  815. if (!prev[deletedId]) return prev
  816. const next = { ...prev }
  817. delete next[deletedId]
  818. return next
  819. })
  820. const isCurrent = currentSession?.id === deletedId
  821. if (isCurrent) {
  822. newVirtual()
  823. }
  824. setReasoning(deletedId, false)
  825. }
  826. }
  827. const handleSessionStatus = (event: any) => {
  828. if (event.type !== "session.status" || !event.properties) return
  829. const { sessionID, status } = event.properties as {
  830. sessionID: string
  831. status: SessionStatusInfo
  832. }
  833. setStatusMap((prev) => {
  834. if (status.type === "idle") {
  835. const next = { ...prev }
  836. delete next[sessionID]
  837. return next
  838. }
  839. return { ...prev, [sessionID]: status }
  840. })
  841. }
  842. const handleSessionDiff = (event: any) => {
  843. if (event.type !== "session.diff" || !event.properties) return
  844. const { sessionID, diff } = event.properties as { sessionID: string; diff: FileDiff[] }
  845. if (!sessionID) return
  846. setSessionDiffMap((prev) => ({ ...prev, [sessionID]: Array.isArray(diff) ? diff : [] }))
  847. }
  848. const unsubscribeCreated = eventEmitter.on("session.created", handleSessionCreated)
  849. const unsubscribeUpdated = eventEmitter.on("session.updated", handleSessionUpdated)
  850. const unsubscribeDeleted = eventEmitter.on("session.deleted", handleSessionDeleted)
  851. const unsubscribeStatus = eventEmitter.on("session.status", handleSessionStatus)
  852. const unsubscribeDiff = eventEmitter.on("session.diff", handleSessionDiff)
  853. return () => {
  854. unsubscribeCreated()
  855. unsubscribeUpdated()
  856. unsubscribeDeleted()
  857. unsubscribeStatus()
  858. unsubscribeDiff()
  859. }
  860. }, [currentSession?.id, setReasoning, newVirtual])
  861. const value: SessionContextState = {
  862. currentSession,
  863. setCurrentSession,
  864. sessions,
  865. setSessions,
  866. isCreating,
  867. isLoading,
  868. error,
  869. isIdle,
  870. setIsIdle,
  871. isReasoning,
  872. setReasoning,
  873. sessionDiff: sessionDiffMap,
  874. currentStatus,
  875. selectedProviderId,
  876. selectedModelId,
  877. selectedAgent,
  878. setSelectedModel,
  879. setSelectedAgent,
  880. selectedVariant,
  881. setSelectedVariant,
  882. isVirtualSession,
  883. newVirtual,
  884. createSession,
  885. materializeSession,
  886. loadSessions,
  887. switchSession,
  888. updateSessionTitle,
  889. deleteSession,
  890. forkSession,
  891. revertToMessage,
  892. unrevertSession,
  893. redoNext,
  894. retrySession,
  895. clearError,
  896. }
  897. return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
  898. }