2026-03-31 18:47:00 +03:00
|
|
|
import type {
|
|
|
|
|
Event,
|
|
|
|
|
Message,
|
|
|
|
|
Part,
|
|
|
|
|
PermissionRequest,
|
|
|
|
|
Project,
|
|
|
|
|
QuestionRequest,
|
|
|
|
|
Session,
|
|
|
|
|
SessionStatus,
|
|
|
|
|
Todo,
|
|
|
|
|
} from "@opencode-ai/sdk/v2/client"
|
|
|
|
|
import { Binary } from "./binary"
|
2026-04-12 00:40:49 +03:00
|
|
|
import type { FileDiff, GlobalState, State } from "./types"
|
2026-03-31 18:47:00 +03:00
|
|
|
import { dropSessionCaches } from "./session-cache"
|
|
|
|
|
import { stripSessionDiffSnapshots } from "./sanitize"
|
2026-04-12 04:40:47 +08:00
|
|
|
import { syncDebug } from "./debug"
|
2026-07-11 23:19:24 +11:00
|
|
|
import { shouldSkipStaleSessionEvent } from "./session-event-freshness"
|
2026-08-14 16:53:05 +03:00
|
|
|
import {
|
|
|
|
|
compareMessagesChronologically,
|
|
|
|
|
findMessageIndex,
|
|
|
|
|
insertMessageChronologically,
|
|
|
|
|
} from "./message-ordering"
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
2026-04-15 15:12:30 +08:00
|
|
|
const DELTA_OVERLAP_FIELDS = ["text", "output"] as const
|
2026-04-16 17:02:23 +03:00
|
|
|
const FINAL_TOOL_STATUSES = new Set(["completed", "error", "aborted", "failed", "timeout", "cancelled"])
|
2026-04-15 15:12:30 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-03-31 18:47:00 +03:00
|
|
|
|
2026-04-16 17:02:23 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 08:56:18 -04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 18:49:27 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Global events
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export type GlobalEventResult = {
|
|
|
|
|
type: "refresh"
|
|
|
|
|
} | {
|
|
|
|
|
type: "project"
|
|
|
|
|
project: Project
|
|
|
|
|
} | null
|
|
|
|
|
|
2026-07-01 18:32:16 +03:00
|
|
|
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"
|
2026-07-30 00:28:46 +03:00
|
|
|
| "settled-running-tool"
|
2026-07-01 18:32:16 +03:00
|
|
|
|
2026-05-07 18:57:44 +03:00
|
|
|
export type DirectoryEventResult = boolean | {
|
|
|
|
|
changed: boolean
|
|
|
|
|
materialization: {
|
|
|
|
|
type: "incomplete-session-snapshot"
|
2026-07-01 18:32:16 +03:00
|
|
|
reason: SessionMaterializationReason
|
2026-05-07 18:57:44 +03:00
|
|
|
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
|
2026-08-14 16:53:05 +03:00
|
|
|
return messages.some((message) => message.id === messageID)
|
2026-05-07 18:57:44 +03:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
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
|
|
|
|
|
},
|
2026-05-07 18:57:44 +03:00
|
|
|
): DirectoryEventResult {
|
2026-07-21 20:52:20 +03:00
|
|
|
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]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
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)
|
2026-07-11 23:19:24 +11:00
|
|
|
if (result.found && shouldSkipStaleSessionEvent(sessions[result.index], info)) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-03-31 18:47:00 +03:00
|
|
|
if (result.found) {
|
|
|
|
|
sessions[result.index] = info
|
|
|
|
|
} else {
|
|
|
|
|
sessions.splice(result.index, 0, info)
|
|
|
|
|
trimSessions(draft)
|
|
|
|
|
if (!info.parentID) draft.sessionTotal += 1
|
|
|
|
|
}
|
2026-07-21 20:52:20 +03:00
|
|
|
markSessionEvent(info.id, false)
|
2026-03-31 18:47:00 +03:00
|
|
|
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)
|
2026-07-11 23:19:24 +11:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
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)
|
2026-07-21 20:52:20 +03:00
|
|
|
markSessionEvent(info.id, true)
|
2026-03-31 18:47:00 +03:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (result.found) {
|
|
|
|
|
sessions[result.index] = info
|
|
|
|
|
} else {
|
|
|
|
|
sessions.splice(result.index, 0, info)
|
|
|
|
|
trimSessions(draft)
|
|
|
|
|
}
|
2026-07-21 20:52:20 +03:00
|
|
|
markSessionEvent(info.id, false)
|
2026-03-31 18:47:00 +03:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "session.deleted": {
|
|
|
|
|
const sessions = draft.session
|
2026-07-21 20:52:20 +03:00
|
|
|
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)
|
2026-03-31 18:47:00 +03:00
|
|
|
if (result.found) sessions.splice(result.index, 1)
|
2026-07-21 20:52:20 +03:00
|
|
|
cleanupSessionCaches(draft, sessionID, callbacks?.onSetSessionTodo)
|
|
|
|
|
if (!info?.parentID) draft.sessionTotal = Math.max(0, draft.sessionTotal - 1)
|
|
|
|
|
markSessionEvent(sessionID, true)
|
2026-03-31 18:47:00 +03:00
|
|
|
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[] }
|
2026-07-15 19:47:54 +11:00
|
|
|
if (areJsonEquivalent(draft.todo[props.sessionID], props.todos)) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-03-31 18:47:00 +03:00
|
|
|
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 }
|
2026-05-08 08:56:18 -04:00
|
|
|
if (areSessionStatusesEqual(draft.session_status[props.sessionID], props.status)) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-03-31 18:47:00 +03:00
|
|
|
draft.session_status[props.sessionID] = props.status
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 20:33:01 +03:00
|
|
|
case "session.idle": {
|
|
|
|
|
const props = event.properties as { sessionID: string }
|
2026-05-08 08:56:18 -04:00
|
|
|
const status = { type: "idle" } as const
|
|
|
|
|
if (areSessionStatusesEqual(draft.session_status[props.sessionID], status)) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
draft.session_status[props.sessionID] = status
|
2026-04-07 20:33:01 +03:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "session.error": {
|
|
|
|
|
const props = event.properties as { sessionID: string }
|
2026-05-08 08:56:18 -04:00
|
|
|
const status = { type: "idle" } as const
|
|
|
|
|
if (areSessionStatusesEqual(draft.session_status[props.sessionID], status)) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
draft.session_status[props.sessionID] = status
|
2026-04-07 20:33:01 +03:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-14 16:53:05 +03:00
|
|
|
const messageIndex = findMessageIndex(messages, info.id)
|
|
|
|
|
if (messageIndex >= 0) {
|
2026-03-31 18:47:00 +03:00
|
|
|
// Skip message replacement if unchanged — preserves reference, avoids re-render
|
2026-08-14 16:53:05 +03:00
|
|
|
const existing = messages[messageIndex]
|
2026-06-03 18:49:27 +03:00
|
|
|
const unchanged = areMessageUpdateFieldsEqual(existing, info)
|
2026-03-31 18:47:00 +03:00
|
|
|
if (unchanged) {
|
2026-04-12 04:40:47 +08:00
|
|
|
syncDebug.reducer.messageUpdatedUnchanged(info.sessionID, info.id, info.role, (info as { finish?: unknown }).finish, (info.time as { completed?: number })?.completed)
|
2026-03-31 18:47:00 +03:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
const next = [...messages]
|
2026-08-14 16:53:05 +03:00
|
|
|
if (compareMessagesChronologically(existing, info) === 0) {
|
|
|
|
|
next[messageIndex] = info
|
|
|
|
|
} else {
|
|
|
|
|
next.splice(messageIndex, 1)
|
|
|
|
|
insertMessageChronologically(next, info)
|
|
|
|
|
}
|
2026-03-31 18:47:00 +03:00
|
|
|
draft.message[info.sessionID] = next
|
|
|
|
|
} else {
|
|
|
|
|
const next = [...messages]
|
2026-08-14 16:53:05 +03:00
|
|
|
insertMessageChronologically(next, info)
|
2026-03-31 18:47:00 +03:00
|
|
|
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]
|
2026-08-14 16:53:05 +03:00
|
|
|
const messageIndex = findMessageIndex(next, props.messageID)
|
|
|
|
|
if (messageIndex >= 0) {
|
|
|
|
|
next.splice(messageIndex, 1)
|
2026-03-31 18:47:00 +03:00
|
|
|
draft.message[props.sessionID] = next
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
delete draft.part[props.messageID]
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "message.part.updated": {
|
2026-06-04 13:32:16 +03:00
|
|
|
const props = event.properties as { sessionID?: string; part: Part }
|
|
|
|
|
const part = props.part
|
2026-04-12 04:40:47 +08:00
|
|
|
if (SKIP_PARTS.has(part.type)) {
|
|
|
|
|
syncDebug.reducer.partSkipped((part as { messageID: string }).messageID, part.id, part.type)
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-06-04 13:32:16 +03:00
|
|
|
const messageID = (part as { messageID?: string }).messageID
|
|
|
|
|
const sessionID = props.sessionID ?? (part as { sessionID?: string }).sessionID
|
|
|
|
|
if (!messageID) return false
|
2026-05-07 18:57:44 +03:00
|
|
|
const missingOwningMessage = !hasMessage(draft, sessionID, messageID)
|
2026-03-31 18:47:00 +03:00
|
|
|
const parts = draft.part[messageID]
|
|
|
|
|
if (!parts) {
|
2026-04-12 04:40:47 +08:00
|
|
|
syncDebug.reducer.partUpdatedNoExistingParts(messageID, part.id, part.type)
|
2026-03-31 18:47:00 +03:00
|
|
|
draft.part[messageID] = [part]
|
2026-05-07 18:57:44 +03:00
|
|
|
return missingOwningMessage
|
|
|
|
|
? {
|
|
|
|
|
changed: true,
|
2026-07-01 18:32:16 +03:00
|
|
|
materialization: { type: "incomplete-session-snapshot", reason: "missing-owning-message", sessionID, messageID, partID: part.id },
|
2026-05-07 18:57:44 +03:00
|
|
|
}
|
|
|
|
|
: true
|
2026-03-31 18:47:00 +03:00
|
|
|
}
|
|
|
|
|
const next = [...parts]
|
2026-08-14 16:53:05 +03:00
|
|
|
const partIndex = next.findIndex((candidate) => candidate.id === part.id)
|
|
|
|
|
if (partIndex >= 0) {
|
|
|
|
|
const previous = next[partIndex]
|
2026-04-16 17:02:23 +03:00
|
|
|
if (shouldPreserveExistingPart(previous, part)) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-04-15 15:12:30 +08:00
|
|
|
const dedupeFields = getUpdatedDeltaFields(previous, part)
|
2026-08-14 16:53:05 +03:00
|
|
|
next[partIndex] = dedupeFields.length > 0
|
2026-04-15 15:12:30 +08:00
|
|
|
? { ...part, __dedupeNextDeltaFields: dedupeFields } as unknown as Part
|
|
|
|
|
: part
|
2026-03-31 18:47:00 +03:00
|
|
|
} 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
|
2026-08-14 16:53:05 +03:00
|
|
|
const optimisticIndex = hasOptimistic && (part.type === "text" || part.type === "file")
|
2026-03-31 18:47:00 +03:00
|
|
|
? next.findIndex((p) => p.type === part.type && !(p as { sessionID?: string }).sessionID)
|
|
|
|
|
: -1
|
2026-08-14 16:53:05 +03:00
|
|
|
if (optimisticIndex >= 0) {
|
2026-08-22 01:55:52 +03:00
|
|
|
// 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)
|
2026-03-31 18:47:00 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
draft.part[messageID] = next
|
2026-05-07 18:57:44 +03:00
|
|
|
return missingOwningMessage
|
|
|
|
|
? {
|
|
|
|
|
changed: true,
|
2026-07-01 18:32:16 +03:00
|
|
|
materialization: { type: "incomplete-session-snapshot", reason: "missing-owning-message", sessionID, messageID, partID: part.id },
|
2026-05-07 18:57:44 +03:00
|
|
|
}
|
|
|
|
|
: true
|
2026-03-31 18:47:00 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "message.part.removed": {
|
|
|
|
|
const props = event.properties as { messageID: string; partID: string }
|
|
|
|
|
const parts = draft.part[props.messageID]
|
|
|
|
|
if (!parts) return false
|
2026-08-14 16:53:05 +03:00
|
|
|
const partIndex = parts.findIndex((part) => part.id === props.partID)
|
|
|
|
|
if (partIndex >= 0) {
|
2026-03-31 18:47:00 +03:00
|
|
|
const next = [...parts]
|
2026-08-14 16:53:05 +03:00
|
|
|
next.splice(partIndex, 1)
|
2026-03-31 18:47:00 +03:00
|
|
|
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 {
|
2026-06-04 13:32:16 +03:00
|
|
|
sessionID?: string
|
2026-03-31 18:47:00 +03:00
|
|
|
messageID: string
|
|
|
|
|
partID: string
|
|
|
|
|
field: string
|
|
|
|
|
delta: string
|
|
|
|
|
}
|
|
|
|
|
const parts = draft.part[props.messageID]
|
2026-04-12 04:40:47 +08:00
|
|
|
if (!parts) {
|
|
|
|
|
syncDebug.reducer.partDeltaNoParts(props.messageID, props.partID)
|
2026-05-07 18:57:44 +03:00
|
|
|
return {
|
|
|
|
|
changed: false,
|
2026-07-01 18:32:16 +03:00
|
|
|
materialization: { type: "incomplete-session-snapshot", reason: "orphan-delta", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID },
|
2026-05-07 18:57:44 +03:00
|
|
|
}
|
2026-04-12 04:40:47 +08:00
|
|
|
}
|
2026-08-14 16:53:05 +03:00
|
|
|
const partIndex = parts.findIndex((part) => part.id === props.partID)
|
|
|
|
|
if (partIndex < 0) {
|
2026-04-12 04:40:47 +08:00
|
|
|
syncDebug.reducer.partDeltaNotFound(props.messageID, props.partID)
|
2026-05-07 18:57:44 +03:00
|
|
|
return {
|
|
|
|
|
changed: false,
|
2026-07-01 18:32:16 +03:00
|
|
|
materialization: { type: "incomplete-session-snapshot", reason: "missing-delta-part", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID },
|
2026-05-07 18:57:44 +03:00
|
|
|
}
|
2026-04-12 04:40:47 +08:00
|
|
|
}
|
2026-08-14 16:53:05 +03:00
|
|
|
const existing = parts[partIndex] as Record<string, unknown>
|
2026-03-31 18:47:00 +03:00
|
|
|
const existingValue = existing[props.field] as string | undefined
|
2026-04-15 15:12:30 +08:00
|
|
|
const dedupeFields = (existing as DedupeMetadata).__dedupeNextDeltaFields ?? []
|
|
|
|
|
const shouldDedupe = dedupeFields.includes(props.field)
|
2026-03-31 18:47:00 +03:00
|
|
|
// Create new Part object + new array so React detects the change
|
|
|
|
|
const next = [...parts]
|
2026-08-14 16:53:05 +03:00
|
|
|
next[partIndex] = {
|
2026-04-15 15:12:30 +08:00
|
|
|
...existing,
|
|
|
|
|
[props.field]: shouldDedupe ? appendNonOverlappingDelta(existingValue, props.delta) : (existingValue ?? "") + props.delta,
|
|
|
|
|
__dedupeNextDeltaFields: dedupeFields.filter((field) => field !== props.field),
|
|
|
|
|
} as unknown as Part
|
2026-03-31 18:47:00 +03:00
|
|
|
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] ?? []
|
2026-05-08 08:07:40 -04:00
|
|
|
const next = [...permissions]
|
|
|
|
|
const result = Binary.search(next, permission.id, (p) => p.id)
|
2026-03-31 18:47:00 +03:00
|
|
|
if (result.found) {
|
2026-05-08 08:07:40 -04:00
|
|
|
next[result.index] = permission
|
2026-03-31 18:47:00 +03:00
|
|
|
} else {
|
2026-05-08 08:07:40 -04:00
|
|
|
next.splice(result.index, 0, permission)
|
2026-03-31 18:47:00 +03:00
|
|
|
}
|
2026-05-08 08:07:40 -04:00
|
|
|
draft.permission[permission.sessionID] = next
|
2026-03-31 18:47:00 +03:00
|
|
|
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) {
|
2026-05-08 08:07:40 -04:00
|
|
|
const next = [...permissions]
|
|
|
|
|
next.splice(result.index, 1)
|
|
|
|
|
draft.permission[props.sessionID] = next
|
2026-03-31 18:47:00 +03:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "question.asked": {
|
|
|
|
|
const question = event.properties as QuestionRequest
|
|
|
|
|
const questions = draft.question[question.sessionID] ?? []
|
2026-05-08 08:07:40 -04:00
|
|
|
const next = [...questions]
|
|
|
|
|
const result = Binary.search(next, question.id, (q) => q.id)
|
2026-03-31 18:47:00 +03:00
|
|
|
if (result.found) {
|
2026-05-08 08:07:40 -04:00
|
|
|
next[result.index] = question
|
2026-03-31 18:47:00 +03:00
|
|
|
} else {
|
2026-05-08 08:07:40 -04:00
|
|
|
next.splice(result.index, 0, question)
|
2026-03-31 18:47:00 +03:00
|
|
|
}
|
2026-05-08 08:07:40 -04:00
|
|
|
draft.question[question.sessionID] = next
|
2026-03-31 18:47:00 +03:00
|
|
|
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) {
|
2026-05-08 08:07:40 -04:00
|
|
|
const next = [...questions]
|
|
|
|
|
next.splice(result.index, 1)
|
|
|
|
|
draft.question[props.sessionID] = next
|
2026-03-31 18:47:00 +03:00
|
|
|
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])
|
|
|
|
|
}
|