Files
openchamber/packages/ui/src/sync/streaming.ts
T
Bohdan Triapitsyn 85400459e9 perf: overhaul session loading, caching, and runtime isolation (#2360)
Improve OpenChamber responsiveness under large session workloads while fixing
cache, synchronization, and persistence correctness across runtimes, projects,
directories, and worktrees.

- prioritize selected and visible sessions during bootstrap and defer
  non-critical enrichment work
- reduce redundant message loading, event processing, store publication, and
  hidden sidebar work
- prevent stale session and message requests from overwriting newer
  authoritative state
- preserve existing data when authoritative fetches fail instead of treating
  failures as successful empty responses
- scope session materialization, messages, drafts, queues, todos, pins,
  permissions, folders, tabs, Git state, and pull request data by runtime and
  directory identity
- harden runtime switching, reconnect, cleanup, mutation reconciliation, and
  persisted-state ordering
- preserve live subagent Task linkage when metadata arrives after an older
  message request or while streaming parts are suspended
- coalesce overlapping tail refreshes without losing newer refresh demand
- improve cold-session loading by moving deferrable work out of the critical
  bootstrap path
- isolate URL authentication, mobile credentials, native secrets, and other
  runtime-owned state across endpoint changes
- bound long-lived caches and remove avoidable allocations from event and
  rendering hot paths
- limit virtualization to archive collections where it improves rendering
  without disrupting active sidebar layout
- stabilize session folders, pin ordering, expanded state, and persisted
  sidebar behavior
- open skill files through the same secure editor and outside-workspace grant
  flow used by file navigation, including worktree sessions
- expand regression coverage for stale completions, runtime collisions,
  reconnect behavior, persistence races, authoritative empty results, and
  subagent refresh ordering
- document the updated synchronization, cache ownership, performance, and
  runtime-isolation invariants
2026-07-21 20:52:20 +03:00

265 lines
8.8 KiB
TypeScript

/**
* Streaming lifecycle tracking.
*
* Derives streaming state from the sync child store's session_status and
* message/part updates. Components read this to know which messages are
* currently streaming and their lifecycle phase.
*/
import { create } from "zustand"
import type { Message, SessionStatus } from "@opencode-ai/sdk/v2/client"
import type { State } from "./types"
import { countSyncPerformance } from "./performance-diagnostics"
type StreamPhase = "streaming" | "cooldown" | "completed"
type MessageStreamState = {
phase: StreamPhase
startedAt: number
lastUpdateAt: number
completedAt?: number
}
export type StreamingStore = {
/** Currently streaming message per session */
streamingMessageIds: Map<string, string | null>
/** Lifecycle phase per message */
messageStreamStates: Map<string, MessageStreamState>
}
export const useStreamingStore = create<StreamingStore>()(() => ({
streamingMessageIds: new Map(),
messageStreamStates: new Map(),
}))
export function resetStreamingState() {
useStreamingStore.setState({
streamingMessageIds: new Map(),
messageStreamStates: new Map(),
})
}
/**
* Called from the SyncBridge/flush handler when child store state changes.
* Derives streaming state from session_status + messages.
*/
/** Only update lastUpdateAt every this many ms to avoid 60Hz store churn */
const STREAMING_HEARTBEAT_MS = 1000
const findTrailingAssistantMessage = (messages: Message[] | undefined): Message | null => {
if (!messages) return null
for (let index = messages.length - 1; index >= 0; index -= 1) {
countSyncPerformance("streamingMessagesVisited")
if (messages[index].role === "user") return null
if (messages[index].role === "assistant") return messages[index]
}
return null
}
export function updateStreamingState(state: State, now = Date.now()) {
countSyncPerformance("streamingFullReconciliations")
const currentStore = useStreamingStore.getState()
const currentStreamingIds = currentStore.streamingMessageIds
const currentStreamStates = currentStore.messageStreamStates
const nextStreamingIds = new Map<string, string | null>()
const nextStreamStates = new Map(currentStreamStates)
let changed = false
// Fast path: only scan sessions that are actually busy.
// Idle sessions are handled by checking against currentStreamingIds below.
const busySessionIds = new Set<string>()
for (const [sessionID, status] of Object.entries(state.session_status ?? {})) {
countSyncPerformance("streamingStatusEntriesVisited")
if ((status as SessionStatus).type === "busy") {
busySessionIds.add(sessionID)
}
}
const completeStreamingMessage = (sessionID: string, msgId: string) => {
nextStreamingIds.set(sessionID, null)
const existing = nextStreamStates.get(msgId)
if (existing && existing.phase === "streaming") {
nextStreamStates.set(msgId, {
...existing,
phase: "completed",
completedAt: now,
})
}
changed = true
}
for (const sessionID of busySessionIds) {
countSyncPerformance("streamingSessionCandidatesVisited")
const messages = state.message[sessionID]
if (!messages || messages.length === 0) continue
// Only the trailing assistant turn can be streaming. If a new user turn is
// last, the next assistant message has not arrived yet.
const streamingMsg = findTrailingAssistantMessage(messages)
if (!streamingMsg) {
const prevId = currentStreamingIds.get(sessionID)
if (prevId) {
completeStreamingMessage(sessionID, prevId)
}
continue
}
const prevId = currentStreamingIds.get(sessionID)
if (prevId !== streamingMsg.id) changed = true
nextStreamingIds.set(sessionID, streamingMsg.id)
const existing = nextStreamStates.get(streamingMsg.id)
if (!existing || existing.phase !== "streaming") {
nextStreamStates.set(streamingMsg.id, {
phase: "streaming",
startedAt: existing?.startedAt ?? now,
lastUpdateAt: now,
})
changed = true
} else if (now - existing.lastUpdateAt >= STREAMING_HEARTBEAT_MS) {
// Throttle lastUpdateAt writes to ~1Hz instead of 60Hz
nextStreamStates.set(streamingMsg.id, {
...existing,
lastUpdateAt: now,
})
changed = true
}
}
// Mark completed any previously streaming sessions that are now idle or gone
for (const [sessionID, msgId] of currentStreamingIds) {
if (!msgId) continue
const isStillBusy = busySessionIds.has(sessionID)
if (isStillBusy) continue
completeStreamingMessage(sessionID, msgId)
}
if (changed) {
useStreamingStore.setState({
streamingMessageIds: nextStreamingIds,
messageStreamStates: nextStreamStates,
})
}
}
const collectChangedSessionIds = (
current: Record<string, unknown> | undefined,
previous: Record<string, unknown> | undefined,
changed: Set<string>,
countStatusEntries = false,
) => {
const currentRecord = current ?? {}
const previousRecord = previous ?? {}
for (const sessionID of Object.keys(currentRecord)) {
if (countStatusEntries) countSyncPerformance("streamingStatusEntriesVisited")
if (currentRecord[sessionID] !== previousRecord[sessionID]) changed.add(sessionID)
}
for (const sessionID of Object.keys(previousRecord)) {
if (Object.prototype.hasOwnProperty.call(currentRecord, sessionID)) continue
if (countStatusEntries) countSyncPerformance("streamingStatusEntriesVisited")
changed.add(sessionID)
}
}
export function updateChangedStreamingSessions(state: State, previous: State, now = Date.now()): void {
const statusChanged = state.session_status !== previous.session_status
const messagesChanged = state.message !== previous.message
if (!statusChanged && !messagesChanged) return
countSyncPerformance("streamingIncrementalReconciliations")
const changedSessionIds = new Set<string>()
if (statusChanged) {
collectChangedSessionIds(
state.session_status as Record<string, unknown> | undefined,
previous.session_status as Record<string, unknown> | undefined,
changedSessionIds,
true,
)
}
if (messagesChanged) {
collectChangedSessionIds(
state.message as Record<string, unknown> | undefined,
previous.message as Record<string, unknown> | undefined,
changedSessionIds,
)
}
if (changedSessionIds.size === 0) return
const currentStore = useStreamingStore.getState()
const nextStreamingIds = new Map(currentStore.streamingMessageIds)
const nextStreamStates = new Map(currentStore.messageStreamStates)
let changed = false
const complete = (sessionID: string, messageID: string) => {
if (nextStreamingIds.get(sessionID) !== null) {
nextStreamingIds.set(sessionID, null)
changed = true
}
const existing = nextStreamStates.get(messageID)
if (existing?.phase === "streaming") {
nextStreamStates.set(messageID, { ...existing, phase: "completed", completedAt: now })
changed = true
}
}
for (const sessionID of changedSessionIds) {
countSyncPerformance("streamingSessionCandidatesVisited")
const previousMessageID = currentStore.streamingMessageIds.get(sessionID)
if (state.session_status?.[sessionID]?.type !== "busy") {
if (previousMessageID) complete(sessionID, previousMessageID)
continue
}
const streamingMessage = findTrailingAssistantMessage(state.message[sessionID])
if (!streamingMessage) {
if (previousMessageID) complete(sessionID, previousMessageID)
continue
}
if (previousMessageID && previousMessageID !== streamingMessage.id) {
complete(sessionID, previousMessageID)
}
if (nextStreamingIds.get(sessionID) !== streamingMessage.id) {
nextStreamingIds.set(sessionID, streamingMessage.id)
changed = true
}
const existing = nextStreamStates.get(streamingMessage.id)
if (!existing || existing.phase !== "streaming") {
nextStreamStates.set(streamingMessage.id, {
phase: "streaming",
startedAt: existing?.startedAt ?? now,
lastUpdateAt: now,
})
changed = true
}
}
if (changed) {
useStreamingStore.setState({
streamingMessageIds: nextStreamingIds,
messageStreamStates: nextStreamStates,
})
}
}
export function touchStreamingSession(sessionID: string, now = Date.now()): void {
countSyncPerformance("streamingHeartbeatAttempts")
const current = useStreamingStore.getState()
const messageID = current.streamingMessageIds.get(sessionID)
if (!messageID) return
const existing = current.messageStreamStates.get(messageID)
if (!existing || existing.phase !== "streaming" || now - existing.lastUpdateAt < STREAMING_HEARTBEAT_MS) return
const messageStreamStates = new Map(current.messageStreamStates)
messageStreamStates.set(messageID, { ...existing, lastUpdateAt: now })
countSyncPerformance("streamingHeartbeatCommits")
useStreamingStore.setState({ messageStreamStates })
}