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:
@@ -9,6 +9,7 @@ import type {
|
|||||||
EventLspUpdated,
|
EventLspUpdated,
|
||||||
|
|
||||||
EventSessionCompacted,
|
EventSessionCompacted,
|
||||||
|
EventSessionDiff,
|
||||||
EventSessionError,
|
EventSessionError,
|
||||||
EventSessionIdle,
|
EventSessionIdle,
|
||||||
EventSessionUpdated,
|
EventSessionUpdated,
|
||||||
@@ -59,8 +60,10 @@ type SSEEvent =
|
|||||||
| MessagePartRemovedEvent
|
| MessagePartRemovedEvent
|
||||||
| EventSessionUpdated
|
| EventSessionUpdated
|
||||||
| EventSessionCompacted
|
| EventSessionCompacted
|
||||||
|
| EventSessionDiff
|
||||||
| EventSessionError
|
| EventSessionError
|
||||||
| EventSessionIdle
|
| EventSessionIdle
|
||||||
|
| EventSessionStatus
|
||||||
| { type: "permission.updated" | "permission.asked"; properties?: any }
|
| { type: "permission.updated" | "permission.asked"; properties?: any }
|
||||||
| { type: "permission.replied"; properties?: any }
|
| { type: "permission.replied"; properties?: any }
|
||||||
| { type: "question.asked"; properties?: any }
|
| { type: "question.asked"; properties?: any }
|
||||||
@@ -139,6 +142,9 @@ class SSEManager {
|
|||||||
case "session.status":
|
case "session.status":
|
||||||
this.onSessionStatus?.(instanceId, event as EventSessionStatus)
|
this.onSessionStatus?.(instanceId, event as EventSessionStatus)
|
||||||
break
|
break
|
||||||
|
case "session.diff":
|
||||||
|
this.onSessionDiff?.(instanceId, event as EventSessionDiff)
|
||||||
|
break
|
||||||
case "permission.updated":
|
case "permission.updated":
|
||||||
case "permission.asked":
|
case "permission.asked":
|
||||||
this.onPermissionUpdated?.(instanceId, event as any)
|
this.onPermissionUpdated?.(instanceId, event as any)
|
||||||
@@ -185,6 +191,7 @@ class SSEManager {
|
|||||||
onTuiToast?: (instanceId: string, event: TuiToastEvent) => void
|
onTuiToast?: (instanceId: string, event: TuiToastEvent) => void
|
||||||
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
|
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
|
||||||
onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
|
onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
|
||||||
|
onSessionDiff?: (instanceId: string, event: EventSessionDiff) => void
|
||||||
onPermissionUpdated?: (instanceId: string, event: any) => void
|
onPermissionUpdated?: (instanceId: string, event: any) => void
|
||||||
onPermissionReplied?: (instanceId: string, event: any) => void
|
onPermissionReplied?: (instanceId: string, event: any) => void
|
||||||
onQuestionAsked?: (instanceId: string, event: any) => void
|
onQuestionAsked?: (instanceId: string, event: any) => void
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
|
import { mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
|
||||||
import type { Message } from "../types/message"
|
import type { Message } from "../types/message"
|
||||||
|
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
import { instances } from "./instances"
|
import { instances } from "./instances"
|
||||||
import { preferences, setAgentModelPreference } from "./preferences"
|
import { preferences, setAgentModelPreference } from "./preferences"
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
setSessionInfoByInstance,
|
setSessionInfoByInstance,
|
||||||
setSessions,
|
setSessions,
|
||||||
sessions,
|
sessions,
|
||||||
|
withSession,
|
||||||
loading,
|
loading,
|
||||||
setLoading,
|
setLoading,
|
||||||
cleanupBlankSessions,
|
cleanupBlankSessions,
|
||||||
@@ -42,6 +44,49 @@ import {
|
|||||||
|
|
||||||
const log = getLogger("api")
|
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 {
|
interface SessionForkResponse {
|
||||||
id: string
|
id: string
|
||||||
title?: string
|
title?: string
|
||||||
@@ -570,6 +615,11 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
|||||||
throw new Error("Session not found")
|
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) => {
|
setLoading((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
const loadingSet = next.loadingMessages.get(instanceId) || new Set()
|
const loadingSet = next.loadingMessages.get(instanceId) || new Set()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
} from "../types/message"
|
} from "../types/message"
|
||||||
import type {
|
import type {
|
||||||
EventSessionCompacted,
|
EventSessionCompacted,
|
||||||
|
EventSessionDiff,
|
||||||
EventSessionError,
|
EventSessionError,
|
||||||
EventSessionIdle,
|
EventSessionIdle,
|
||||||
EventSessionUpdated,
|
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 {
|
function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
|
||||||
const sessionId = event.properties?.sessionID
|
const sessionId = event.properties?.sessionID
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
@@ -605,6 +631,7 @@ export {
|
|||||||
handleQuestionAsked,
|
handleQuestionAsked,
|
||||||
handleQuestionAnswered,
|
handleQuestionAnswered,
|
||||||
handleSessionCompacted,
|
handleSessionCompacted,
|
||||||
|
handleSessionDiff,
|
||||||
handleSessionError,
|
handleSessionError,
|
||||||
handleSessionIdle,
|
handleSessionIdle,
|
||||||
handleSessionStatus,
|
handleSessionStatus,
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
handleQuestionAnswered,
|
handleQuestionAnswered,
|
||||||
handleQuestionAsked,
|
handleQuestionAsked,
|
||||||
handleSessionCompacted,
|
handleSessionCompacted,
|
||||||
|
handleSessionDiff,
|
||||||
handleSessionError,
|
handleSessionError,
|
||||||
handleSessionIdle,
|
handleSessionIdle,
|
||||||
handleSessionStatus,
|
handleSessionStatus,
|
||||||
@@ -77,6 +78,7 @@ sseManager.onMessageRemoved = handleMessageRemoved
|
|||||||
sseManager.onMessagePartRemoved = handleMessagePartRemoved
|
sseManager.onMessagePartRemoved = handleMessagePartRemoved
|
||||||
sseManager.onSessionUpdate = handleSessionUpdate
|
sseManager.onSessionUpdate = handleSessionUpdate
|
||||||
sseManager.onSessionCompacted = handleSessionCompacted
|
sseManager.onSessionCompacted = handleSessionCompacted
|
||||||
|
sseManager.onSessionDiff = handleSessionDiff
|
||||||
sseManager.onSessionError = handleSessionError
|
sseManager.onSessionError = handleSessionError
|
||||||
sseManager.onSessionIdle = handleSessionIdle
|
sseManager.onSessionIdle = handleSessionIdle
|
||||||
sseManager.onSessionStatus = handleSessionStatus
|
sseManager.onSessionStatus = handleSessionStatus
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
Model as SDKModel,
|
Model as SDKModel,
|
||||||
} from "@opencode-ai/sdk"
|
} from "@opencode-ai/sdk"
|
||||||
import type { SessionStatus as SDKSessionStatus } from "@opencode-ai/sdk/v2/client"
|
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 SDK types for external use
|
||||||
export type {
|
export type {
|
||||||
@@ -39,6 +40,7 @@ export interface Session
|
|||||||
pendingPermission?: boolean // Indicates if session is waiting on user permission
|
pendingPermission?: boolean // Indicates if session is waiting on user permission
|
||||||
pendingQuestion?: boolean // Indicates if session is waiting on user input
|
pendingQuestion?: boolean // Indicates if session is waiting on user input
|
||||||
status: SessionStatus // Single source of truth for session status
|
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
|
// Adapter function to convert SDK Session to client Session
|
||||||
|
|||||||
Reference in New Issue
Block a user