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.
This commit is contained in:
Shantur Rathore
2026-02-09 12:02:15 +00:00
parent 0e755b721c
commit 4279b25ff4
5 changed files with 88 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import type {
EventLspUpdated,
EventSessionCompacted,
EventSessionDiff,
EventSessionError,
EventSessionIdle,
EventSessionUpdated,
@@ -59,8 +60,10 @@ type SSEEvent =
| MessagePartRemovedEvent
| EventSessionUpdated
| EventSessionCompacted
| EventSessionDiff
| EventSessionError
| EventSessionIdle
| EventSessionStatus
| { type: "permission.updated" | "permission.asked"; properties?: any }
| { type: "permission.replied"; properties?: any }
| { type: "question.asked"; properties?: any }
@@ -139,6 +142,9 @@ class SSEManager {
case "session.status":
this.onSessionStatus?.(instanceId, event as EventSessionStatus)
break
case "session.diff":
this.onSessionDiff?.(instanceId, event as EventSessionDiff)
break
case "permission.updated":
case "permission.asked":
this.onPermissionUpdated?.(instanceId, event as any)
@@ -185,6 +191,7 @@ class SSEManager {
onTuiToast?: (instanceId: string, event: TuiToastEvent) => void
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
onSessionDiff?: (instanceId: string, event: EventSessionDiff) => void
onPermissionUpdated?: (instanceId: string, event: any) => void
onPermissionReplied?: (instanceId: string, event: any) => void
onQuestionAsked?: (instanceId: string, event: any) => void

View File

@@ -1,5 +1,6 @@
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"
@@ -19,6 +20,7 @@ import {
setSessionInfoByInstance,
setSessions,
sessions,
withSession,
loading,
setLoading,
cleanupBlankSessions,
@@ -42,6 +44,49 @@ import {
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
@@ -570,6 +615,11 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
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()

View File

@@ -7,6 +7,7 @@ import type {
} from "../types/message"
import type {
EventSessionCompacted,
EventSessionDiff,
EventSessionError,
EventSessionIdle,
EventSessionUpdated,
@@ -428,6 +429,31 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
}
}
function handleSessionDiff(instanceId: string, event: EventSessionDiff): void {
const sessionId = event.properties?.sessionID
if (!sessionId) return
const diffs = event.properties?.diff
if (!Array.isArray(diffs)) return
const existing = sessions().get(instanceId)?.get(sessionId)
if (existing) {
withSession(instanceId, sessionId, (session) => {
session.diff = diffs
})
return
}
// A diff event can arrive before we have hydrated the session list.
// Best-effort: fetch the session record so the diff has somewhere to live.
void (async () => {
await fetchSessionInfo(instanceId, sessionId, (event as any)?.directory)
withSession(instanceId, sessionId, (session) => {
session.diff = diffs
})
})().catch((error) => log.warn("Failed to hydrate session for diff event", { instanceId, sessionId, error }))
}
function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
const sessionId = event.properties?.sessionID
if (!sessionId) return
@@ -605,6 +631,7 @@ export {
handleQuestionAsked,
handleQuestionAnswered,
handleSessionCompacted,
handleSessionDiff,
handleSessionError,
handleSessionIdle,
handleSessionStatus,

View File

@@ -64,6 +64,7 @@ import {
handleQuestionAnswered,
handleQuestionAsked,
handleSessionCompacted,
handleSessionDiff,
handleSessionError,
handleSessionIdle,
handleSessionStatus,
@@ -77,6 +78,7 @@ sseManager.onMessageRemoved = handleMessageRemoved
sseManager.onMessagePartRemoved = handleMessagePartRemoved
sseManager.onSessionUpdate = handleSessionUpdate
sseManager.onSessionCompacted = handleSessionCompacted
sseManager.onSessionDiff = handleSessionDiff
sseManager.onSessionError = handleSessionError
sseManager.onSessionIdle = handleSessionIdle
sseManager.onSessionStatus = handleSessionStatus

View File

@@ -5,6 +5,7 @@ import type {
Model as SDKModel,
} from "@opencode-ai/sdk"
import type { SessionStatus as SDKSessionStatus } from "@opencode-ai/sdk/v2/client"
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
// Export SDK types for external use
export type {
@@ -39,6 +40,7 @@ export interface Session
pendingPermission?: boolean // Indicates if session is waiting on user permission
pendingQuestion?: boolean // Indicates if session is waiting on user input
status: SessionStatus // Single source of truth for session status
diff?: FileDiff[] // Session-level file diffs (hydrated via session.diff)
}
// Adapter function to convert SDK Session to client Session