From 4279b25ff40aa01a89c4cd38c21df5d2337ba587 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 9 Feb 2026 12:02:15 +0000 Subject: [PATCH] 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. --- packages/ui/src/lib/sse-manager.ts | 7 ++++ packages/ui/src/stores/session-api.ts | 50 ++++++++++++++++++++++++ packages/ui/src/stores/session-events.ts | 27 +++++++++++++ packages/ui/src/stores/sessions.ts | 2 + packages/ui/src/types/session.ts | 2 + 5 files changed, 88 insertions(+) diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index 98d32d81..d777ef38 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -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 diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index fcc653af..dc8ad63e 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -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>() + +async function loadSessionDiff(instanceId: string, sessionId: string, force = false): Promise { + 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( + 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() diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 44a0ecbe..f5516a8a 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -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, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 48f5298c..ef056a0e 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -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 diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index e26f0a06..1cae7c21 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -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