Files
openchamber/packages/ui/src/sync/event-reducer.ts
T
Bohdan Triapitsyn cd46f0e3fb fix(chat): stop the just-sent user message from flickering
The server echo of an optimistic user message carries different part
ids, so the reducer dropped the optimistic part and appended the server
one at the end, and MessageBody keyed user parts by part id. The key
change remounted the text subtree (blank frame, markdown re-parse,
truncation state reset) and the append reordered text against file
parts.

Replace the optimistic part in place and key user parts positionally.
2026-08-22 01:55:52 +03:00

620 lines
21 KiB
TypeScript

import type {
Event,
Message,
Part,
PermissionRequest,
Project,
QuestionRequest,
Session,
SessionStatus,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary"
import type { FileDiff, GlobalState, State } from "./types"
import { dropSessionCaches } from "./session-cache"
import { stripSessionDiffSnapshots } from "./sanitize"
import { syncDebug } from "./debug"
import { shouldSkipStaleSessionEvent } from "./session-event-freshness"
import {
compareMessagesChronologically,
findMessageIndex,
insertMessageChronologically,
} from "./message-ordering"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const DELTA_OVERLAP_FIELDS = ["text", "output"] as const
const FINAL_TOOL_STATUSES = new Set(["completed", "error", "aborted", "failed", "timeout", "cancelled"])
type DedupeMetadata = {
__dedupeNextDeltaFields?: string[]
}
function appendNonOverlappingDelta(existingValue: string | undefined, delta: string) {
if (!existingValue || delta.length === 0) return (existingValue ?? "") + delta
if (existingValue.endsWith(delta)) return existingValue
const maxOverlap = Math.min(existingValue.length, delta.length)
for (let overlap = maxOverlap; overlap > 0; overlap--) {
if (existingValue.endsWith(delta.slice(0, overlap))) {
return existingValue + delta.slice(overlap)
}
}
return existingValue + delta
}
function getUpdatedDeltaFields(previous: Part, next: Part) {
const dedupeFields: string[] = []
for (const field of DELTA_OVERLAP_FIELDS) {
const previousValue = (previous as Record<string, unknown>)[field]
const nextValue = (next as Record<string, unknown>)[field]
if (typeof previousValue !== "string" || typeof nextValue !== "string") continue
if (previousValue.length === 0 || nextValue.length === 0) continue
if (nextValue === previousValue || nextValue.startsWith(previousValue) || previousValue.startsWith(nextValue)) {
dedupeFields.push(field)
}
}
return dedupeFields
}
function getPartEndTime(part: Part): number | undefined {
const stateEnd = (part as { state?: { time?: { end?: unknown } } }).state?.time?.end
if (typeof stateEnd === "number") {
return stateEnd
}
const timeEnd = (part as { time?: { end?: unknown } }).time?.end
return typeof timeEnd === "number" ? timeEnd : undefined
}
function getToolStatus(part: Part): string | undefined {
if (part.type !== "tool") {
return undefined
}
const status = (part as { state?: { status?: unknown } }).state?.status
return typeof status === "string" ? status : undefined
}
function shouldPreserveExistingPart(previous: Part, next: Part): boolean {
if (previous.type !== "tool" || next.type !== "tool") {
return false
}
const previousStatus = getToolStatus(previous)
const nextStatus = getToolStatus(next)
if (previousStatus && FINAL_TOOL_STATUSES.has(previousStatus) && (!nextStatus || !FINAL_TOOL_STATUSES.has(nextStatus))) {
return true
}
const previousEnd = getPartEndTime(previous)
const nextEnd = getPartEndTime(next)
if (typeof previousEnd === "number" && typeof nextEnd !== "number") {
return true
}
return false
}
function areSessionStatusesEqual(left: SessionStatus | undefined, right: SessionStatus): boolean {
if (left === right) return true
if (!left || left.type !== right.type) return false
if (left.type === "retry") {
return right.type === "retry"
&& left.attempt === right.attempt
&& left.message === right.message
&& left.next === right.next
}
return true
}
function areJsonEquivalent(left: unknown, right: unknown): boolean {
if (left === right) return true
if (left === undefined || right === undefined) return left === right
try {
return JSON.stringify(left) === JSON.stringify(right)
} catch {
return false
}
}
function areMessageUpdateFieldsEqual(existing: Message, next: Message): boolean {
if (existing.role !== next.role) return false
if ((existing as { finish?: unknown }).finish !== (next as { finish?: unknown }).finish) return false
if ((existing.time as { completed?: number })?.completed !== (next.time as { completed?: number })?.completed) return false
const fields: Array<keyof Message | "structured" | "summary" | "tokens" | "error" | "cost" | "model" | "tools" | "format" | "variant" | "agent" | "system"> = [
"summary",
"error",
"cost",
"tokens",
"structured",
"model",
"tools",
"format",
"variant",
"agent",
"system",
]
for (const field of fields) {
if (!areJsonEquivalent((existing as Record<string, unknown>)[field], (next as Record<string, unknown>)[field])) {
return false
}
}
return true
}
// ---------------------------------------------------------------------------
// Global events
// ---------------------------------------------------------------------------
export type GlobalEventResult = {
type: "refresh"
} | {
type: "project"
project: Project
} | null
export type SessionMaterializationReason =
| "missing-owning-message"
| "orphan-delta"
| "missing-delta-part"
| "empty-assistant-message"
| "child-session-idle"
| "child-session-discovered"
| "ensure-session-messages"
| "stream-reconnect"
| "transport-switch"
| "stale-status-resync"
| "settled-running-tool"
export type DirectoryEventResult = boolean | {
changed: boolean
materialization: {
type: "incomplete-session-snapshot"
reason: SessionMaterializationReason
sessionID?: string
messageID: string
partID?: string
}
}
function hasMessage(draft: State, sessionID: string | undefined, messageID: string): boolean {
if (!sessionID) return false
const messages = draft.message[sessionID]
if (!messages) return false
return messages.some((message) => message.id === messageID)
}
export function reduceGlobalEvent(event: Event): GlobalEventResult {
if (event.type === "global.disposed" || event.type === "server.connected") {
return { type: "refresh" }
}
if (event.type === "project.updated") {
return { type: "project", project: event.properties as Project }
}
return null
}
export function applyGlobalProject(state: GlobalState, project: Project): GlobalState {
const projects = [...state.projects]
const result = Binary.search(projects, project.id, (s) => s.id)
if (result.found) {
projects[result.index] = { ...projects[result.index], ...project }
} else {
projects.splice(result.index, 0, project)
}
return { ...state, projects }
}
// ---------------------------------------------------------------------------
// Directory events — mutates draft in place for batching efficiency.
// Caller MUST pass a mutable copy of State (e.g. structuredClone or spread).
// ---------------------------------------------------------------------------
export function applyDirectoryEvent(
draft: State,
event: Event,
callbacks?: {
onRefresh?: (directory: string) => void
onLoadLsp?: () => void
onSetSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
},
): DirectoryEventResult {
const markSessionEvent = (sessionID: string, deleted: boolean) => {
const revision = (draft.sessionRevision ?? 0) + 1
draft.sessionRevision = revision
draft.sessionListSource = "live"
draft.sessionEventRevision = draft.sessionEventRevision ?? {}
draft.sessionDeletedRevision = draft.sessionDeletedRevision ?? {}
if (deleted) {
draft.sessionDeletedRevision[sessionID] = revision
delete draft.sessionEventRevision[sessionID]
} else {
draft.sessionEventRevision[sessionID] = revision
delete draft.sessionDeletedRevision[sessionID]
}
}
switch (event.type) {
case "server.instance.disposed": {
callbacks?.onRefresh?.("")
return false
}
case "session.created": {
const info = stripSessionDiffSnapshots((event.properties as { info: Session }).info)
const sessions = draft.session
const result = Binary.search(sessions, info.id, (s) => s.id)
if (result.found && shouldSkipStaleSessionEvent(sessions[result.index], info)) {
return false
}
if (result.found) {
sessions[result.index] = info
} else {
sessions.splice(result.index, 0, info)
trimSessions(draft)
if (!info.parentID) draft.sessionTotal += 1
}
markSessionEvent(info.id, false)
return true
}
case "session.updated": {
const info = stripSessionDiffSnapshots((event.properties as { info: Session }).info)
const sessions = draft.session
const result = Binary.search(sessions, info.id, (s) => s.id)
// Keep the freshness check ahead of the archive branch: direct archive
// responses handle the store update on their own (optimistic removal +
// SDK response), so stale SSE echoes should not win just because they
// mark the session archived.
if (result.found && shouldSkipStaleSessionEvent(sessions[result.index], info)) {
return false
}
if (info.time.archived) {
if (result.found) sessions.splice(result.index, 1)
cleanupSessionCaches(draft, info.id, callbacks?.onSetSessionTodo)
if (!info.parentID) draft.sessionTotal = Math.max(0, draft.sessionTotal - 1)
markSessionEvent(info.id, true)
return true
}
if (result.found) {
sessions[result.index] = info
} else {
sessions.splice(result.index, 0, info)
trimSessions(draft)
}
markSessionEvent(info.id, false)
return true
}
case "session.deleted": {
const sessions = draft.session
const props = event.properties as { info?: Session; sessionID?: string }
const sessionID = props.info?.id ?? props.sessionID
if (!sessionID) return false
const result = Binary.search(sessions, sessionID, (s) => s.id)
const info = props.info ?? (result.found ? sessions[result.index] : undefined)
if (result.found) sessions.splice(result.index, 1)
cleanupSessionCaches(draft, sessionID, callbacks?.onSetSessionTodo)
if (!info?.parentID) draft.sessionTotal = Math.max(0, draft.sessionTotal - 1)
markSessionEvent(sessionID, true)
return true
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: FileDiff[] }
draft.session_diff[props.sessionID] = props.diff
return true
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
if (areJsonEquivalent(draft.todo[props.sessionID], props.todos)) {
return false
}
draft.todo[props.sessionID] = props.todos
callbacks?.onSetSessionTodo?.(props.sessionID, props.todos)
return true
}
case "session.status": {
const props = event.properties as { sessionID: string; status: SessionStatus }
if (areSessionStatusesEqual(draft.session_status[props.sessionID], props.status)) {
return false
}
draft.session_status[props.sessionID] = props.status
return true
}
case "session.idle": {
const props = event.properties as { sessionID: string }
const status = { type: "idle" } as const
if (areSessionStatusesEqual(draft.session_status[props.sessionID], status)) {
return false
}
draft.session_status[props.sessionID] = status
return true
}
case "session.error": {
const props = event.properties as { sessionID: string }
const status = { type: "idle" } as const
if (areSessionStatusesEqual(draft.session_status[props.sessionID], status)) {
return false
}
draft.session_status[props.sessionID] = status
return true
}
case "message.updated": {
const info = (event.properties as { info: Message }).info
const messages = draft.message[info.sessionID]
if (!messages) {
draft.message[info.sessionID] = [info]
return true
}
const messageIndex = findMessageIndex(messages, info.id)
if (messageIndex >= 0) {
// Skip message replacement if unchanged — preserves reference, avoids re-render
const existing = messages[messageIndex]
const unchanged = areMessageUpdateFieldsEqual(existing, info)
if (unchanged) {
syncDebug.reducer.messageUpdatedUnchanged(info.sessionID, info.id, info.role, (info as { finish?: unknown }).finish, (info.time as { completed?: number })?.completed)
return false
}
const next = [...messages]
if (compareMessagesChronologically(existing, info) === 0) {
next[messageIndex] = info
} else {
next.splice(messageIndex, 1)
insertMessageChronologically(next, info)
}
draft.message[info.sessionID] = next
} else {
const next = [...messages]
insertMessageChronologically(next, info)
draft.message[info.sessionID] = next
}
return true
}
case "message.removed": {
const props = event.properties as { sessionID: string; messageID: string }
const messages = draft.message[props.sessionID]
if (messages) {
const next = [...messages]
const messageIndex = findMessageIndex(next, props.messageID)
if (messageIndex >= 0) {
next.splice(messageIndex, 1)
draft.message[props.sessionID] = next
}
}
delete draft.part[props.messageID]
return true
}
case "message.part.updated": {
const props = event.properties as { sessionID?: string; part: Part }
const part = props.part
if (SKIP_PARTS.has(part.type)) {
syncDebug.reducer.partSkipped((part as { messageID: string }).messageID, part.id, part.type)
return false
}
const messageID = (part as { messageID?: string }).messageID
const sessionID = props.sessionID ?? (part as { sessionID?: string }).sessionID
if (!messageID) return false
const missingOwningMessage = !hasMessage(draft, sessionID, messageID)
const parts = draft.part[messageID]
if (!parts) {
syncDebug.reducer.partUpdatedNoExistingParts(messageID, part.id, part.type)
draft.part[messageID] = [part]
return missingOwningMessage
? {
changed: true,
materialization: { type: "incomplete-session-snapshot", reason: "missing-owning-message", sessionID, messageID, partID: part.id },
}
: true
}
const next = [...parts]
const partIndex = next.findIndex((candidate) => candidate.id === part.id)
if (partIndex >= 0) {
const previous = next[partIndex]
if (shouldPreserveExistingPart(previous, part)) {
return false
}
const dedupeFields = getUpdatedDeltaFields(previous, part)
next[partIndex] = dedupeFields.length > 0
? { ...part, __dedupeNextDeltaFields: dedupeFields } as unknown as Part
: part
} else {
// Replace optimistic part (no sessionID) with server part of same type.
// Gate: only scan if the first part lacks sessionID (optimistic parts are
// always inserted first). Assistant messages never have optimistic parts,
// so this check is effectively free during streaming.
const hasOptimistic = next.length > 0 && !(next[0] as { sessionID?: string }).sessionID
const optimisticIndex = hasOptimistic && (part.type === "text" || part.type === "file")
? next.findIndex((p) => p.type === part.type && !(p as { sessionID?: string }).sessionID)
: -1
if (optimisticIndex >= 0) {
// Replace in place: pushing to the end reorders text/file parts of a
// just-sent message and remounts its rendered subtree.
next[optimisticIndex] = part
} else {
next.push(part)
}
}
draft.part[messageID] = next
return missingOwningMessage
? {
changed: true,
materialization: { type: "incomplete-session-snapshot", reason: "missing-owning-message", sessionID, messageID, partID: part.id },
}
: true
}
case "message.part.removed": {
const props = event.properties as { messageID: string; partID: string }
const parts = draft.part[props.messageID]
if (!parts) return false
const partIndex = parts.findIndex((part) => part.id === props.partID)
if (partIndex >= 0) {
const next = [...parts]
next.splice(partIndex, 1)
if (next.length === 0) {
delete draft.part[props.messageID]
} else {
draft.part[props.messageID] = next
}
return true
}
return false
}
case "message.part.delta": {
const props = event.properties as {
sessionID?: string
messageID: string
partID: string
field: string
delta: string
}
const parts = draft.part[props.messageID]
if (!parts) {
syncDebug.reducer.partDeltaNoParts(props.messageID, props.partID)
return {
changed: false,
materialization: { type: "incomplete-session-snapshot", reason: "orphan-delta", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID },
}
}
const partIndex = parts.findIndex((part) => part.id === props.partID)
if (partIndex < 0) {
syncDebug.reducer.partDeltaNotFound(props.messageID, props.partID)
return {
changed: false,
materialization: { type: "incomplete-session-snapshot", reason: "missing-delta-part", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID },
}
}
const existing = parts[partIndex] as Record<string, unknown>
const existingValue = existing[props.field] as string | undefined
const dedupeFields = (existing as DedupeMetadata).__dedupeNextDeltaFields ?? []
const shouldDedupe = dedupeFields.includes(props.field)
// Create new Part object + new array so React detects the change
const next = [...parts]
next[partIndex] = {
...existing,
[props.field]: shouldDedupe ? appendNonOverlappingDelta(existingValue, props.delta) : (existingValue ?? "") + props.delta,
__dedupeNextDeltaFields: dedupeFields.filter((field) => field !== props.field),
} as unknown as Part
draft.part[props.messageID] = next
return true
}
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
if (draft.vcs?.branch === props.branch) return false
draft.vcs = { branch: props.branch }
return true
}
case "permission.asked": {
const permission = event.properties as PermissionRequest
const permissions = draft.permission[permission.sessionID] ?? []
const next = [...permissions]
const result = Binary.search(next, permission.id, (p) => p.id)
if (result.found) {
next[result.index] = permission
} else {
next.splice(result.index, 0, permission)
}
draft.permission[permission.sessionID] = next
return true
}
case "permission.replied": {
const props = event.properties as { sessionID: string; requestID: string }
const permissions = draft.permission[props.sessionID]
if (!permissions) return false
const result = Binary.search(permissions, props.requestID, (p) => p.id)
if (result.found) {
const next = [...permissions]
next.splice(result.index, 1)
draft.permission[props.sessionID] = next
return true
}
return false
}
case "question.asked": {
const question = event.properties as QuestionRequest
const questions = draft.question[question.sessionID] ?? []
const next = [...questions]
const result = Binary.search(next, question.id, (q) => q.id)
if (result.found) {
next[result.index] = question
} else {
next.splice(result.index, 0, question)
}
draft.question[question.sessionID] = next
return true
}
case "question.replied":
case "question.rejected": {
const props = event.properties as { sessionID: string; requestID: string }
const questions = draft.question[props.sessionID]
if (!questions) return false
const result = Binary.search(questions, props.requestID, (q) => q.id)
if (result.found) {
const next = [...questions]
next.splice(result.index, 1)
draft.question[props.sessionID] = next
return true
}
return false
}
case "lsp.updated": {
callbacks?.onLoadLsp?.()
return false
}
default:
return false
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function trimSessions(draft: State) {
if (draft.session.length <= draft.limit) return
// Keep sessions that have pending permissions (they need to stay visible)
const hasPermission = new Set(
Object.entries(draft.permission ?? {})
.filter(([, perms]) => perms && perms.length > 0)
.map(([sessionID]) => sessionID),
)
while (draft.session.length > draft.limit) {
// Remove from the beginning (oldest by sorted ID)
const candidate = draft.session[0]
if (hasPermission.has(candidate.id)) break
draft.session.shift()
}
}
function cleanupSessionCaches(
draft: State,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return
setSessionTodo?.(sessionID, undefined)
dropSessionCaches(draft, [sessionID])
}