Files
CodeNomad/packages/ui/src/stores/session-api.ts
Shantur Rathore 4279b25ff4 feat(ui): hydrate session diffs on open
Fetch session-level diffs when a session is opened and keep them updated via session.diff SSE events so UI state stays in sync with server changes.
2026-02-09 12:02:15 +00:00

774 lines
23 KiB
TypeScript

import { mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
import type { Message } from "../types/message"
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
import { instances } from "./instances"
import { preferences, setAgentModelPreference } from "./preferences"
import {
activeSessionId,
agents,
clearSessionDraftPrompt,
getChildSessions,
isBlankSession,
messagesLoaded,
pruneDraftPrompts,
providers,
setActiveSessionId,
setAgents,
setMessagesLoaded,
setProviders,
setSessionInfoByInstance,
setSessions,
sessions,
withSession,
loading,
setLoading,
cleanupBlankSessions,
syncInstanceSessionIndicator,
} from "./session-state"
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info"
import { seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForSession } from "../lib/global-cache"
import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api"
import {
getOrCreateWorktreeClient,
getRootClient,
getWorktreeSlugForSession,
removeParentSessionMapping,
setWorktreeSlugForParentSession,
} from "./worktrees"
const log = getLogger("api")
const pendingSessionDiffFetches = new Map<string, Promise<void>>()
async function loadSessionDiff(instanceId: string, sessionId: string, force = false): Promise<void> {
if (!instanceId || !sessionId) return
const key = `${instanceId}:${sessionId}`
if (!force) {
const existing = sessions().get(instanceId)?.get(sessionId)
if (existing?.diff !== undefined) return
const pending = pendingSessionDiffFetches.get(key)
if (pending) return pending
}
const promise = (async () => {
const instance = instances().get(instanceId)
if (!instance?.client) return
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
try {
const diffs = await requestData<FileDiff[]>(
client.session.diff({ sessionID: sessionId }),
"session.diff",
)
if (!Array.isArray(diffs)) {
return
}
withSession(instanceId, sessionId, (session) => {
session.diff = diffs
})
} catch (error) {
log.warn("Failed to fetch session diff", { instanceId, sessionId, error })
}
})()
pendingSessionDiffFetches.set(key, promise)
void promise.finally(() => pendingSessionDiffFetches.delete(key))
return promise
}
interface SessionForkResponse {
id: string
title?: string
parentID?: string | null
agent?: string
model?: {
providerID?: string
modelID?: string
}
time?: {
created?: number
updated?: number
}
revert?: {
messageID?: string
partID?: string
snapshot?: string
diff?: string
}
}
async function fetchSessions(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const rootClient = getRootClient(instanceId)
setLoading((prev) => {
const next = { ...prev }
next.fetchingSessions.set(instanceId, true)
return next
})
try {
log.info("session.list", { instanceId })
const response = await rootClient.session.list()
const sessionMap = new Map<string, Session>()
if (!response.data || !Array.isArray(response.data)) {
return
}
let statusById: Record<string, any> = {}
try {
const statusResponse = await rootClient.session.status()
if (statusResponse.data && typeof statusResponse.data === "object") {
statusById = statusResponse.data as Record<string, any>
}
} catch (error) {
log.error("Failed to fetch session status:", error)
}
const existingSessions = sessions().get(instanceId)
for (const apiSession of response.data) {
const existingSession = existingSessions?.get(apiSession.id)
const existingStatus = existingSession?.status
let status: SessionStatus
if (existingStatus === "compacting") {
status = "compacting"
} else {
const rawStatus = (apiSession as any)?.status ?? statusById[apiSession.id]
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle"
}
sessionMap.set(apiSession.id, {
id: apiSession.id,
instanceId,
title: apiSession.title || "Untitled",
parentId: apiSession.parentID || null,
agent: existingSession?.agent ?? "",
model: existingSession?.model ?? { providerId: "", modelId: "" },
status,
version: apiSession.version,
time: {
...apiSession.time,
},
revert: apiSession.revert
? {
messageID: apiSession.revert.messageID,
partID: apiSession.revert.partID,
snapshot: apiSession.revert.snapshot,
diff: apiSession.revert.diff,
}
: undefined,
})
}
const validSessionIds = new Set(sessionMap.keys())
setSessions((prev) => {
const next = new Map(prev)
next.set(instanceId, sessionMap)
return next
})
syncInstanceSessionIndicator(instanceId, sessionMap)
setMessagesLoaded((prev) => {
const next = new Map(prev)
const loadedSet = next.get(instanceId)
if (loadedSet) {
const filtered = new Set<string>()
for (const id of loadedSet) {
if (validSessionIds.has(id)) {
filtered.add(id)
}
}
next.set(instanceId, filtered)
}
return next
})
pruneDraftPrompts(instanceId, new Set(sessionMap.keys()))
} catch (error) {
log.error("Failed to fetch sessions:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
next.fetchingSessions.set(instanceId, false)
return next
})
}
}
async function createSession(instanceId: string, agent?: string): Promise<Session> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
// New parent sessions inherit the currently active session's worktree.
// If no session is active (fresh instance), fall back to root.
const activeId = activeSessionId().get(instanceId)
const worktreeSlug = activeId && activeId !== "info" ? getWorktreeSlugForSession(instanceId, activeId) : "root"
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const instanceAgents = agents().get(instanceId) || []
const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent")
const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "")
const defaultModel = await getDefaultModel(instanceId, selectedAgent)
if (selectedAgent && isModelValid(instanceId, defaultModel)) {
await setAgentModelPreference(instanceId, selectedAgent, defaultModel)
}
setLoading((prev) => {
const next = { ...prev }
next.creatingSession.set(instanceId, true)
return next
})
try {
log.info(`[HTTP] POST /session.create for instance ${instanceId}`)
const response = await client.session.create()
if (!response.data) {
throw new Error("Failed to create session: No data returned")
}
const session: Session = {
id: response.data.id,
instanceId,
title: response.data.title || "New Session",
parentId: null,
agent: selectedAgent,
model: defaultModel,
status: "idle",
version: response.data.version,
time: {
...response.data.time,
},
revert: response.data.revert
? {
messageID: response.data.revert.messageID,
partID: response.data.revert.partID,
snapshot: response.data.revert.snapshot,
diff: response.data.revert.diff,
}
: undefined,
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId) || new Map()
instanceSessions.set(session.id, session)
next.set(instanceId, instanceSessions)
return next
})
syncInstanceSessionIndicator(instanceId)
const instanceProviders = providers().get(instanceId) || []
const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId)
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
const initialContextWindow = initialModel?.limit?.context ?? 0
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
const initialOutputLimit =
initialModel?.limit?.output && initialModel.limit.output > 0
? initialModel.limit.output
: DEFAULT_MODEL_OUTPUT_LIMIT
const initialContextAvailable = initialContextWindow > 0 ? initialContextWindow : null
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(session.id, {
cost: 0,
contextWindow: initialContextWindow,
isSubscriptionModel: Boolean(initialSubscriptionModel),
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: initialOutputLimit,
contextAvailableTokens: initialContextAvailable,
})
next.set(instanceId, instanceInfo)
return next
})
if (preferences().autoCleanupBlankSessions) {
await cleanupBlankSessions(instanceId, session.id)
}
// Persist mapping for this *parent* session (best-effort).
await setWorktreeSlugForParentSession(instanceId, session.id, worktreeSlug).catch((error) => {
log.warn("Failed to persist session worktree mapping", { instanceId, sessionId: session.id, worktreeSlug, error })
})
return session
} catch (error) {
log.error("Failed to create session:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
next.creatingSession.set(instanceId, false)
return next
})
}
}
async function forkSession(
instanceId: string,
sourceSessionId: string,
options?: { messageId?: string },
): Promise<Session> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sourceSessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const request: { sessionID: string; messageID?: string } = {
sessionID: sourceSessionId,
messageID: options?.messageId,
}
log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
const info = await requestData<SessionForkResponse>(
client.session.fork(request),
"session.fork",
)
const forkedSession = {
id: info.id,
instanceId,
title: info.title || "Forked Session",
parentId: info.parentID || null,
agent: info.agent || "",
model: {
providerId: info.model?.providerID || "",
modelId: info.model?.modelID || "",
},
status: "idle",
version: "0",
time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() },
revert: info.revert
? {
messageID: info.revert.messageID,
partID: info.revert.partID,
snapshot: info.revert.snapshot,
diff: info.revert.diff,
}
: undefined,
} as unknown as Session
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId) || new Map()
instanceSessions.set(forkedSession.id, forkedSession)
next.set(instanceId, instanceSessions)
return next
})
syncInstanceSessionIndicator(instanceId)
const instanceProviders = providers().get(instanceId) || []
const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId)
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
const forkContextWindow = forkModel?.limit?.context ?? 0
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
const forkOutputLimit =
forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT
const forkContextAvailable = forkContextWindow > 0 ? forkContextWindow : null
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(forkedSession.id, {
cost: 0,
contextWindow: forkContextWindow,
isSubscriptionModel: Boolean(forkSubscriptionModel),
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: forkOutputLimit,
contextAvailableTokens: forkContextAvailable,
})
next.set(instanceId, instanceInfo)
return next
})
return forkedSession
}
async function deleteSession(instanceId: string, sessionId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const deletingSession = sessions().get(instanceId)?.get(sessionId)
setLoading((prev) => {
const next = { ...prev }
const deleting = next.deletingSession.get(instanceId) || new Set()
deleting.add(sessionId)
next.deletingSession.set(instanceId, deleting)
return next
})
try {
log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
await requestData(client.session.delete({ sessionID: sessionId }), "session.delete")
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId)
if (instanceSessions) {
instanceSessions.delete(sessionId)
if (instanceSessions.size === 0) {
next.delete(instanceId)
}
}
return next
})
syncInstanceSessionIndicator(instanceId)
clearSessionDraftPrompt(instanceId, sessionId)
// Drop normalized message state and caches for this session
messageStoreBus.getOrCreate(instanceId).clearSession(sessionId)
clearCacheForSession(instanceId, sessionId)
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = next.get(instanceId)
if (instanceInfo) {
const updatedInstanceInfo = new Map(instanceInfo)
updatedInstanceInfo.delete(sessionId)
if (updatedInstanceInfo.size === 0) {
next.delete(instanceId)
} else {
next.set(instanceId, updatedInstanceInfo)
}
}
return next
})
if (activeSessionId().get(instanceId) === sessionId) {
setActiveSessionId((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
}
// Clean up mapping for deleted parent sessions.
if (deletingSession?.parentId === null) {
await removeParentSessionMapping(instanceId, sessionId).catch(() => undefined)
}
} catch (error) {
log.error("Failed to delete session:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
const deleting = next.deletingSession.get(instanceId)
if (deleting) {
deleting.delete(sessionId)
}
return next
})
}
}
async function fetchAgents(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const rootClient = getRootClient(instanceId)
try {
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
const response = await rootClient.app.agents()
const agentList = (response.data ?? []).map((agent) => ({
name: agent.name,
description: agent.description || "",
mode: agent.mode,
model: agent.model?.modelID
? {
providerId: agent.model.providerID || "",
modelId: agent.model.modelID,
}
: undefined,
}))
setAgents((prev) => {
const next = new Map(prev)
next.set(instanceId, agentList)
return next
})
} catch (error) {
log.error("Failed to fetch agents:", error)
}
}
async function fetchProviders(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const rootClient = getRootClient(instanceId)
try {
log.info(`[HTTP] GET /config.providers for instance ${instanceId}`)
const response = await rootClient.config.providers()
if (!response.data) return
const providerList = response.data.providers.map((provider) => ({
id: provider.id,
name: provider.name,
defaultModelId: response.data?.default?.[provider.id],
models: Object.entries(provider.models).map(([id, model]) => ({
id,
name: model.name,
providerId: provider.id,
limit: model.limit,
cost: model.cost,
variantKeys: Object.keys(model.variants ?? {}),
})),
}))
setProviders((prev) => {
const next = new Map(prev)
next.set(instanceId, providerList)
return next
})
} catch (error) {
log.error("Failed to fetch providers:", error)
}
}
async function loadMessages(instanceId: string, sessionId: string, force = false): Promise<void> {
if (force) {
setMessagesLoaded((prev) => {
const next = new Map(prev)
const loadedSet = next.get(instanceId)
if (loadedSet) {
loadedSet.delete(sessionId)
}
return next
})
}
const alreadyLoaded = messagesLoaded().get(instanceId)?.has(sessionId)
if (alreadyLoaded && !force) {
return
}
const isLoading = loading().loadingMessages.get(instanceId)?.has(sessionId)
if (isLoading) {
return
}
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
// Fetch session-level diffs in the background once the session is opened.
void loadSessionDiff(instanceId, sessionId).catch((error) => {
log.warn("Failed to load session diff", { instanceId, sessionId, error })
})
setLoading((prev) => {
const next = { ...prev }
const loadingSet = next.loadingMessages.get(instanceId) || new Set()
loadingSet.add(sessionId)
next.loadingMessages.set(instanceId, loadingSet)
return next
})
try {
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
const apiMessages = await requestData<any[]>(
client.session.messages({ sessionID: sessionId }),
"session.messages",
)
if (!Array.isArray(apiMessages)) {
return
}
// Treat empty sessions as loaded to avoid re-fetch loops.
setMessagesLoaded((prev) => {
const next = new Map(prev)
const loadedSet = next.get(instanceId) || new Set()
loadedSet.add(sessionId)
next.set(instanceId, loadedSet)
return next
})
if (apiMessages.length === 0) {
return
}
const messagesInfo = new Map<string, any>()
const messages: Message[] = apiMessages.map((apiMessage: any) => {
const info = apiMessage.info || apiMessage
const role = info.role || "assistant"
const messageId = info.id || String(Date.now())
messagesInfo.set(messageId, info)
const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part))
const message: Message = {
id: messageId,
sessionId,
type: role === "user" ? "user" : "assistant",
parts,
timestamp: info.time?.created || Date.now(),
status: "complete" as const,
version: 0,
}
return message
})
let agentName = ""
let providerID = ""
let modelID = ""
for (let i = apiMessages.length - 1; i >= 0; i--) {
const apiMessage = apiMessages[i]
const info = apiMessage.info || apiMessage
if (info.role === "assistant") {
agentName = (info as any).mode || (info as any).agent || ""
providerID = (info as any).providerID || ""
modelID = (info as any).modelID || ""
if (agentName && providerID && modelID) break
}
}
if (!agentName && !providerID && !modelID) {
const defaultModel = await getDefaultModel(instanceId, session.agent)
agentName = session.agent
providerID = defaultModel.providerId
modelID = defaultModel.modelId
}
setSessions((prev) => {
const next = new Map(prev)
const nextInstanceSessions = next.get(instanceId)
if (!nextInstanceSessions) {
return next
}
const existingSession = nextInstanceSessions.get(sessionId)
if (!existingSession) {
return next
}
const updatedSession = {
...existingSession,
agent: agentName || existingSession.agent,
model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model,
}
nextInstanceSessions.set(sessionId, updatedSession)
next.set(instanceId, nextInstanceSessions)
return next
})
setMessagesLoaded((prev) => {
const next = new Map(prev)
const loadedSet = next.get(instanceId) || new Set()
loadedSet.add(sessionId)
next.set(instanceId, loadedSet)
return next
})
const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? {
id: sessionId,
title: session?.title,
parentId: session?.parentId ?? null,
revert: session?.revert,
}
seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo)
// Permissions can be hydrated before messages/tool parts exist in the store.
// After message hydration, try to attach any pending permissions to tool-call part ids.
reconcilePendingPermissionsV2(instanceId, sessionId)
reconcilePendingQuestionsV2(instanceId, sessionId)
} catch (error) {
log.error("Failed to load messages:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
const loadingSet = next.loadingMessages.get(instanceId)
if (loadingSet) {
loadingSet.delete(sessionId)
}
return next
})
}
updateSessionInfo(instanceId, sessionId)
}
export {
createSession,
deleteSession,
fetchAgents,
fetchProviders,
fetchSessions,
forkSession,
loadMessages,
}