fix: restore live compaction updates in chat
- Show `/compact` command and streamed compaction summary live without refresh - Handle versioned sync event types so message and part updates are processed - Route session/message events via indexed lookup to avoid hot-path store scans
This commit is contained in:
@@ -49,6 +49,87 @@ const resolveMessageRole = (message: ChatMessageEntry): string | null => {
|
|||||||
?? null;
|
?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasCompactionPart = (message: ChatMessageEntry): boolean => {
|
||||||
|
return message.parts.some((part) => {
|
||||||
|
const type = (part as { type?: unknown }).type;
|
||||||
|
return type === 'compaction';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPartText = (part: Part): string => {
|
||||||
|
const text = (part as { text?: unknown }).text;
|
||||||
|
if (typeof text === 'string') {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const content = (part as { content?: unknown }).content;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCompactionCommandMessage = (message: ChatMessageEntry): ChatMessageEntry => {
|
||||||
|
if (!hasCompactionPart(message)) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changedParts = false;
|
||||||
|
const nextParts = message.parts.map((part) => {
|
||||||
|
const type = (part as { type?: unknown }).type;
|
||||||
|
if (type !== 'compaction') {
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
changedParts = true;
|
||||||
|
return { type: 'text', text: '/compact' } as Part;
|
||||||
|
});
|
||||||
|
|
||||||
|
const info = message.info as unknown as { clientRole?: string | null | undefined };
|
||||||
|
const needsClientRole = info.clientRole !== 'user';
|
||||||
|
|
||||||
|
if (!changedParts && !needsClientRole) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
info: needsClientRole
|
||||||
|
? ({
|
||||||
|
...(message.info as unknown as Record<string, unknown>),
|
||||||
|
clientRole: 'user',
|
||||||
|
} as unknown as typeof message.info)
|
||||||
|
: message.info,
|
||||||
|
parts: changedParts ? nextParts : message.parts,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCompactionSummaryMessage = (
|
||||||
|
message: ChatMessageEntry,
|
||||||
|
compactionCommandIds: Set<string>,
|
||||||
|
): ChatMessageEntry => {
|
||||||
|
const role = resolveMessageRole(message);
|
||||||
|
if (role !== 'system') {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentID = getMessageParentId(message);
|
||||||
|
if (!parentID || !compactionCommandIds.has(parentID)) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = message.info as unknown as { clientRole?: string | null | undefined };
|
||||||
|
if (info.clientRole === 'assistant') {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
info: ({
|
||||||
|
...(message.info as unknown as Record<string, unknown>),
|
||||||
|
clientRole: 'assistant',
|
||||||
|
} as unknown as typeof message.info),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const isAssistantMessageCompleted = (message: ChatMessageEntry): boolean => {
|
const isAssistantMessageCompleted = (message: ChatMessageEntry): boolean => {
|
||||||
const info = message.info as { time?: { completed?: unknown }; status?: unknown };
|
const info = message.info as { time?: { completed?: unknown }; status?: unknown };
|
||||||
const completed = info.time?.completed;
|
const completed = info.time?.completed;
|
||||||
@@ -279,11 +360,12 @@ const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageE
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredParts = filterSyntheticParts(message.parts);
|
const normalizedCompactionMessage = normalizeCompactionCommandMessage(message);
|
||||||
const normalized = filteredParts === message.parts
|
const filteredParts = filterSyntheticParts(normalizedCompactionMessage.parts);
|
||||||
? message
|
const normalized = filteredParts === normalizedCompactionMessage.parts
|
||||||
|
? normalizedCompactionMessage
|
||||||
: {
|
: {
|
||||||
...message,
|
...normalizedCompactionMessage,
|
||||||
parts: filteredParts,
|
parts: filteredParts,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1242,12 +1324,17 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
dedupedMessages.reverse();
|
dedupedMessages.reverse();
|
||||||
|
|
||||||
const output: ChatMessageEntry[] = [];
|
const output: ChatMessageEntry[] = [];
|
||||||
|
const compactionCommandIds = new Set<string>();
|
||||||
for (let index = 0; index < dedupedMessages.length; index += 1) {
|
for (let index = 0; index < dedupedMessages.length; index += 1) {
|
||||||
const current = dedupedMessages[index];
|
const current = dedupedMessages[index];
|
||||||
|
const currentWithRole = normalizeCompactionSummaryMessage(current, compactionCommandIds);
|
||||||
|
if (hasCompactionPart(current) || current.parts.some((part) => part.type === 'text' && getPartText(part).trim() === '/compact')) {
|
||||||
|
compactionCommandIds.add(current.info.id);
|
||||||
|
}
|
||||||
const previous = output.length > 0 ? output[output.length - 1] : undefined;
|
const previous = output.length > 0 ? output[output.length - 1] : undefined;
|
||||||
|
|
||||||
if (isUserSubtaskMessage(previous)) {
|
if (isUserSubtaskMessage(previous)) {
|
||||||
const bridge = isSyntheticSubtaskBridgeAssistant(current);
|
const bridge = isSyntheticSubtaskBridgeAssistant(currentWithRole);
|
||||||
if (bridge.hide) {
|
if (bridge.hide) {
|
||||||
output[output.length - 1] = withSubtaskSessionId(previous as ChatMessageEntry, bridge.taskSessionId);
|
output[output.length - 1] = withSubtaskSessionId(previous as ChatMessageEntry, bridge.taskSessionId);
|
||||||
continue;
|
continue;
|
||||||
@@ -1255,14 +1342,14 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isUserShellMarkerMessage(previous)) {
|
if (isUserShellMarkerMessage(previous)) {
|
||||||
const bridge = getShellBridgeAssistantDetails(current, getMessageId(previous));
|
const bridge = getShellBridgeAssistantDetails(currentWithRole, getMessageId(previous));
|
||||||
if (bridge.hide) {
|
if (bridge.hide) {
|
||||||
output[output.length - 1] = withShellBridgeDetails(previous as ChatMessageEntry, bridge.details);
|
output[output.length - 1] = withShellBridgeDetails(previous as ChatMessageEntry, bridge.details);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
output.push(current);
|
output.push(currentWithRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputIndexById = new Map<string, number>();
|
const outputIndexById = new Map<string, number>();
|
||||||
|
|||||||
@@ -41,6 +41,23 @@ export type EventPipelineInput = {
|
|||||||
onReconnect?: () => void
|
onReconnect?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeEventType = (payload: Event): Event => {
|
||||||
|
const type = (payload as { type?: unknown }).type
|
||||||
|
if (typeof type !== "string") {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^(.*)\.(\d+)$/.exec(type)
|
||||||
|
if (!match || !match[1]) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
type: match[1] as Event["type"],
|
||||||
|
} as unknown as Event
|
||||||
|
}
|
||||||
|
|
||||||
function resolveEventDirectory(event: unknown, payload: Event): string {
|
function resolveEventDirectory(event: unknown, payload: Event): string {
|
||||||
const directDirectory =
|
const directDirectory =
|
||||||
typeof event === "object" && event !== null && typeof (event as { directory?: unknown }).directory === "string"
|
typeof event === "object" && event !== null && typeof (event as { directory?: unknown }).directory === "string"
|
||||||
@@ -190,21 +207,22 @@ export function createEventPipeline(input: EventPipelineInput) {
|
|||||||
if (!payload || typeof payload !== "object" || typeof (payload as { type?: unknown }).type !== "string") {
|
if (!payload || typeof payload !== "object" || typeof (payload as { type?: unknown }).type !== "string") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const directory = resolveEventDirectory(event, payload)
|
const normalizedPayload = normalizeEventType(payload)
|
||||||
const k = key(directory, payload)
|
const directory = resolveEventDirectory(event, normalizedPayload)
|
||||||
|
const k = key(directory, normalizedPayload)
|
||||||
if (k) {
|
if (k) {
|
||||||
const i = coalesced.get(k)
|
const i = coalesced.get(k)
|
||||||
if (i !== undefined) {
|
if (i !== undefined) {
|
||||||
queue[i] = { directory, payload }
|
queue[i] = { directory, payload: normalizedPayload }
|
||||||
if (payload.type === "message.part.updated") {
|
if (normalizedPayload.type === "message.part.updated") {
|
||||||
const part = (payload.properties as { part: { messageID: string; id: string } }).part
|
const part = (normalizedPayload.properties as { part: { messageID: string; id: string } }).part
|
||||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
coalesced.set(k, queue.length)
|
coalesced.set(k, queue.length)
|
||||||
}
|
}
|
||||||
queue.push({ directory, payload })
|
queue.push({ directory, payload: normalizedPayload })
|
||||||
schedule()
|
schedule()
|
||||||
|
|
||||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
|
|||||||
@@ -178,7 +178,350 @@ function toSessionStatus(status: Awaited<ReturnType<typeof opencodeClient.getSes
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resyncDirectoryAfterReconnect(directory: string, store: StoreApi<DirectoryStore>) {
|
type EventRoutingIndex = {
|
||||||
|
sessionDirectoryById: Map<string, string>
|
||||||
|
messageSessionById: Map<string, string>
|
||||||
|
sessionMessageIdsById: Map<string, Set<string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const createEventRoutingIndex = (): EventRoutingIndex => ({
|
||||||
|
sessionDirectoryById: new Map(),
|
||||||
|
messageSessionById: new Map(),
|
||||||
|
sessionMessageIdsById: new Map(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeEventDirectory = (rawDirectory: string): string => {
|
||||||
|
if (!rawDirectory || rawDirectory === "global") {
|
||||||
|
return rawDirectory
|
||||||
|
}
|
||||||
|
return rawDirectory.replace(/\\/g, "/").replace(/^([a-z]):/, (_, l: string) => l.toUpperCase() + ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSessionIdFromPayload = (event: Event): string | null => {
|
||||||
|
const properties = (event as { properties?: unknown }).properties
|
||||||
|
if (!properties || typeof properties !== "object") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = properties as Record<string, unknown>
|
||||||
|
|
||||||
|
if (event.type === "message.updated") {
|
||||||
|
const info = props.info
|
||||||
|
if (!info || typeof info !== "object") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const sessionID = (info as { sessionID?: unknown }).sessionID
|
||||||
|
return typeof sessionID === "string" && sessionID.length > 0 ? sessionID : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.type === "message.removed"
|
||||||
|
|| event.type === "session.status"
|
||||||
|
|| event.type === "todo.updated"
|
||||||
|
|| event.type === "permission.asked"
|
||||||
|
|| event.type === "permission.replied"
|
||||||
|
|| event.type === "question.asked"
|
||||||
|
|| event.type === "question.replied"
|
||||||
|
|| event.type === "question.rejected"
|
||||||
|
|| event.type === "session.deleted"
|
||||||
|
) {
|
||||||
|
const sessionID = props.sessionID
|
||||||
|
return typeof sessionID === "string" && sessionID.length > 0 ? sessionID : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "message.part.updated") {
|
||||||
|
const part = props.part
|
||||||
|
if (!part || typeof part !== "object") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const sessionID = (part as { sessionID?: unknown }).sessionID
|
||||||
|
return typeof sessionID === "string" && sessionID.length > 0 ? sessionID : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session.created" || event.type === "session.updated") {
|
||||||
|
const info = props.info
|
||||||
|
if (!info || typeof info !== "object") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const id = (info as { id?: unknown }).id
|
||||||
|
return typeof id === "string" && id.length > 0 ? id : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMessageIdFromPayload = (event: Event): string | null => {
|
||||||
|
const properties = (event as { properties?: unknown }).properties
|
||||||
|
if (!properties || typeof properties !== "object") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = properties as Record<string, unknown>
|
||||||
|
|
||||||
|
if (event.type === "message.updated") {
|
||||||
|
const info = props.info
|
||||||
|
if (!info || typeof info !== "object") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const id = (info as { id?: unknown }).id
|
||||||
|
return typeof id === "string" && id.length > 0 ? id : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "message.removed" || event.type === "message.part.delta" || event.type === "message.part.removed") {
|
||||||
|
const messageID = props.messageID
|
||||||
|
return typeof messageID === "string" && messageID.length > 0 ? messageID : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "message.part.updated") {
|
||||||
|
const part = props.part
|
||||||
|
if (!part || typeof part !== "object") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const messageID = (part as { messageID?: unknown }).messageID
|
||||||
|
return typeof messageID === "string" && messageID.length > 0 ? messageID : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const setIndexedSessionDirectory = (routingIndex: EventRoutingIndex, sessionID: string, directory: string) => {
|
||||||
|
if (!sessionID || !directory || directory === "global") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
routingIndex.sessionDirectoryById.set(sessionID, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setIndexedSessionMessages = (
|
||||||
|
routingIndex: EventRoutingIndex,
|
||||||
|
sessionID: string,
|
||||||
|
directory: string,
|
||||||
|
messages: Message[],
|
||||||
|
) => {
|
||||||
|
if (!sessionID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIndexedSessionDirectory(routingIndex, sessionID, directory)
|
||||||
|
|
||||||
|
const previous = routingIndex.sessionMessageIdsById.get(sessionID)
|
||||||
|
const next = new Set<string>()
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (!message?.id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next.add(message.id)
|
||||||
|
routingIndex.messageSessionById.set(message.id, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous) {
|
||||||
|
for (const previousMessageID of previous) {
|
||||||
|
if (!next.has(previousMessageID)) {
|
||||||
|
routingIndex.messageSessionById.delete(previousMessageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
routingIndex.sessionMessageIdsById.set(sessionID, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setIndexedMessage = (
|
||||||
|
routingIndex: EventRoutingIndex,
|
||||||
|
sessionID: string,
|
||||||
|
messageID: string,
|
||||||
|
directory: string,
|
||||||
|
) => {
|
||||||
|
if (!sessionID || !messageID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIndexedSessionDirectory(routingIndex, sessionID, directory)
|
||||||
|
routingIndex.messageSessionById.set(messageID, sessionID)
|
||||||
|
|
||||||
|
const existing = routingIndex.sessionMessageIdsById.get(sessionID)
|
||||||
|
if (existing) {
|
||||||
|
existing.add(messageID)
|
||||||
|
} else {
|
||||||
|
routingIndex.sessionMessageIdsById.set(sessionID, new Set([messageID]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeIndexedMessage = (
|
||||||
|
routingIndex: EventRoutingIndex,
|
||||||
|
messageID: string,
|
||||||
|
sessionHint?: string | null,
|
||||||
|
) => {
|
||||||
|
if (!messageID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionID = sessionHint ?? routingIndex.messageSessionById.get(messageID)
|
||||||
|
routingIndex.messageSessionById.delete(messageID)
|
||||||
|
|
||||||
|
if (!sessionID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageIds = routingIndex.sessionMessageIdsById.get(sessionID)
|
||||||
|
if (!messageIds) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
messageIds.delete(messageID)
|
||||||
|
if (messageIds.size === 0) {
|
||||||
|
routingIndex.sessionMessageIdsById.delete(sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeIndexedSession = (routingIndex: EventRoutingIndex, sessionID: string) => {
|
||||||
|
if (!sessionID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
routingIndex.sessionDirectoryById.delete(sessionID)
|
||||||
|
const messageIds = routingIndex.sessionMessageIdsById.get(sessionID)
|
||||||
|
if (messageIds) {
|
||||||
|
for (const messageID of messageIds) {
|
||||||
|
routingIndex.messageSessionById.delete(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routingIndex.sessionMessageIdsById.delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ingestDirectoryStateIntoRoutingIndex = (
|
||||||
|
routingIndex: EventRoutingIndex,
|
||||||
|
directory: string,
|
||||||
|
state: State,
|
||||||
|
) => {
|
||||||
|
const nextSessionIds = new Set<string>()
|
||||||
|
|
||||||
|
for (const session of state.session) {
|
||||||
|
if (!session?.id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextSessionIds.add(session.id)
|
||||||
|
setIndexedSessionDirectory(routingIndex, session.id, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sessionID of Object.keys(state.message)) {
|
||||||
|
nextSessionIds.add(sessionID)
|
||||||
|
setIndexedSessionDirectory(routingIndex, sessionID, directory)
|
||||||
|
setIndexedSessionMessages(routingIndex, sessionID, directory, state.message[sessionID] ?? EMPTY_MESSAGES)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [indexedSessionID, indexedDirectory] of routingIndex.sessionDirectoryById) {
|
||||||
|
if (indexedDirectory !== directory) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!nextSessionIds.has(indexedSessionID)) {
|
||||||
|
removeIndexedSession(routingIndex, indexedSessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveDirectoryFromRoutingIndex = (
|
||||||
|
routingIndex: EventRoutingIndex,
|
||||||
|
rawDirectory: string,
|
||||||
|
payload: Event,
|
||||||
|
childStores: ChildStoreManager,
|
||||||
|
): string => {
|
||||||
|
const normalizedDirectory = normalizeEventDirectory(rawDirectory)
|
||||||
|
|
||||||
|
const sessionID = getSessionIdFromPayload(payload)
|
||||||
|
if (sessionID) {
|
||||||
|
const indexedDirectory = routingIndex.sessionDirectoryById.get(sessionID)
|
||||||
|
if (indexedDirectory) {
|
||||||
|
return indexedDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageID = getMessageIdFromPayload(payload)
|
||||||
|
if (messageID) {
|
||||||
|
const sessionFromMessage = routingIndex.messageSessionById.get(messageID)
|
||||||
|
if (sessionFromMessage) {
|
||||||
|
const indexedDirectory = routingIndex.sessionDirectoryById.get(sessionFromMessage)
|
||||||
|
if (indexedDirectory) {
|
||||||
|
return indexedDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!normalizedDirectory || normalizedDirectory === "global") && childStores.children.size === 1) {
|
||||||
|
const onlyDirectory = childStores.children.keys().next().value
|
||||||
|
if (typeof onlyDirectory === "string" && onlyDirectory.length > 0) {
|
||||||
|
return onlyDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRoutingIndexFromEvent = (
|
||||||
|
routingIndex: EventRoutingIndex,
|
||||||
|
directory: string,
|
||||||
|
payload: Event,
|
||||||
|
) => {
|
||||||
|
if (!directory || directory === "global") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionID = getSessionIdFromPayload(payload)
|
||||||
|
if (sessionID) {
|
||||||
|
setIndexedSessionDirectory(routingIndex, sessionID, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (payload.type) {
|
||||||
|
case "session.created":
|
||||||
|
case "session.updated": {
|
||||||
|
const info = (payload.properties as { info?: Session }).info
|
||||||
|
if (info?.id) {
|
||||||
|
setIndexedSessionDirectory(routingIndex, info.id, directory)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case "session.deleted": {
|
||||||
|
const deletedSessionID = (payload.properties as { sessionID?: string }).sessionID
|
||||||
|
if (deletedSessionID) {
|
||||||
|
removeIndexedSession(routingIndex, deletedSessionID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message.updated": {
|
||||||
|
const info = (payload.properties as { info?: Message }).info
|
||||||
|
if (info?.id && info.sessionID) {
|
||||||
|
setIndexedMessage(routingIndex, info.sessionID, info.id, directory)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message.removed": {
|
||||||
|
const props = payload.properties as { sessionID?: string; messageID?: string }
|
||||||
|
if (props.messageID) {
|
||||||
|
removeIndexedMessage(routingIndex, props.messageID, props.sessionID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message.part.updated": {
|
||||||
|
const part = (payload.properties as { part?: Part }).part as (Part & { sessionID?: string; messageID?: string }) | undefined
|
||||||
|
if (part?.messageID && part.sessionID) {
|
||||||
|
setIndexedMessage(routingIndex, part.sessionID, part.messageID, directory)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resyncDirectoryAfterReconnect(
|
||||||
|
directory: string,
|
||||||
|
store: StoreApi<DirectoryStore>,
|
||||||
|
routingIndex: EventRoutingIndex,
|
||||||
|
) {
|
||||||
const current = store.getState()
|
const current = store.getState()
|
||||||
const candidateSessionIds = getReconnectCandidateSessionIds(current)
|
const candidateSessionIds = getReconnectCandidateSessionIds(current)
|
||||||
if (candidateSessionIds.length === 0) return
|
if (candidateSessionIds.length === 0) return
|
||||||
@@ -256,20 +599,22 @@ async function resyncDirectoryAfterReconnect(directory: string, store: StoreApi<
|
|||||||
part: nextPartState,
|
part: nextPartState,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setIndexedSessionDirectory(routingIndex, nextSession.id, directory)
|
||||||
|
setIndexedSessionMessages(routingIndex, sessionId, directory, nextMessages)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEvent(
|
function handleEvent(
|
||||||
rawDirectory: string,
|
rawDirectory: string,
|
||||||
payload: Event,
|
payload: Event,
|
||||||
childStores: ChildStoreManager,
|
childStores: ChildStoreManager,
|
||||||
|
routingIndex: EventRoutingIndex,
|
||||||
) {
|
) {
|
||||||
// Normalize directory path: SSE events from OpenCode use native OS separators
|
const directory = resolveDirectoryFromRoutingIndex(routingIndex, rawDirectory, payload, childStores)
|
||||||
// (backslashes on Windows) and may differ in drive-letter case.
|
|
||||||
// Child stores are keyed with forward slashes and uppercase drive letters.
|
|
||||||
const directory = rawDirectory && rawDirectory !== "global"
|
|
||||||
? rawDirectory.replace(/\\/g, "/").replace(/^([a-z]):/, (_, l: string) => l.toUpperCase() + ":")
|
|
||||||
: rawDirectory
|
|
||||||
// Global events
|
// Global events
|
||||||
if (directory === "global" || !directory) {
|
if (directory === "global" || !directory) {
|
||||||
const recent = isRecentBoot()
|
const recent = isRecentBoot()
|
||||||
@@ -305,6 +650,7 @@ function handleEvent(
|
|||||||
|
|
||||||
// Directory events
|
// Directory events
|
||||||
const store = childStores.getChild(directory)
|
const store = childStores.getChild(directory)
|
||||||
|
|
||||||
if (!store) {
|
if (!store) {
|
||||||
// Try as global event for unknown directories
|
// Try as global event for unknown directories
|
||||||
const result = reduceGlobalEvent(payload)
|
const result = reduceGlobalEvent(payload)
|
||||||
@@ -402,6 +748,8 @@ function handleEvent(
|
|||||||
store.setState(draft)
|
store.setState(draft)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateRoutingIndexFromEvent(routingIndex, directory, payload)
|
||||||
|
|
||||||
// Update global session status for cross-directory sidebar visibility
|
// Update global session status for cross-directory sidebar visibility
|
||||||
if (payload.type === "session.status") {
|
if (payload.type === "session.status") {
|
||||||
const props = payload.properties as { sessionID: string; status: SessionStatus }
|
const props = payload.properties as { sessionID: string; status: SessionStatus }
|
||||||
@@ -435,6 +783,9 @@ export function SyncProvider(props: {
|
|||||||
const childStoresRef = useRef<ChildStoreManager | null>(null)
|
const childStoresRef = useRef<ChildStoreManager | null>(null)
|
||||||
if (!childStoresRef.current) childStoresRef.current = new ChildStoreManager()
|
if (!childStoresRef.current) childStoresRef.current = new ChildStoreManager()
|
||||||
const childStores = childStoresRef.current
|
const childStores = childStoresRef.current
|
||||||
|
const routingIndexRef = useRef<EventRoutingIndex | null>(null)
|
||||||
|
if (!routingIndexRef.current) routingIndexRef.current = createEventRoutingIndex()
|
||||||
|
const routingIndex = routingIndexRef.current
|
||||||
|
|
||||||
const system = useMemo<SyncSystem>(
|
const system = useMemo<SyncSystem>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -465,6 +816,9 @@ export function SyncProvider(props: {
|
|||||||
getState: () => store.getState(),
|
getState: () => store.getState(),
|
||||||
set: (patch) => {
|
set: (patch) => {
|
||||||
store.setState(patch)
|
store.setState(patch)
|
||||||
|
if (patch.session || patch.message) {
|
||||||
|
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
||||||
|
}
|
||||||
if (patch.session_status) {
|
if (patch.session_status) {
|
||||||
const current = useGlobalSessionStatusStore.getState().statuses
|
const current = useGlobalSessionStatusStore.getState().statuses
|
||||||
const merged = { ...current, ...patch.session_status }
|
const merged = { ...current, ...patch.session_status }
|
||||||
@@ -491,6 +845,7 @@ export function SyncProvider(props: {
|
|||||||
.filter((s) => !!s?.id)
|
.filter((s) => !!s?.id)
|
||||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||||
store.setState({ session: sessions, sessionTotal: sessions.length, limit: Math.max(sessions.length, 50) })
|
store.setState({ session: sessions, sessionTotal: sessions.length, limit: Math.max(sessions.length, 50) })
|
||||||
|
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -514,7 +869,7 @@ export function SyncProvider(props: {
|
|||||||
isBooting: (directory) => bootingDirs.has(directory),
|
isBooting: (directory) => bootingDirs.has(directory),
|
||||||
isLoadingSessions: () => false,
|
isLoadingSessions: () => false,
|
||||||
})
|
})
|
||||||
}, [childStores, props.sdk])
|
}, [childStores, props.sdk, routingIndex])
|
||||||
|
|
||||||
// Bootstrap global state — set bootingRoot/bootedAt to suppress
|
// Bootstrap global state — set bootingRoot/bootedAt to suppress
|
||||||
// redundant refresh events during startup
|
// redundant refresh events during startup
|
||||||
@@ -538,7 +893,7 @@ export function SyncProvider(props: {
|
|||||||
const { cleanup } = createEventPipeline({
|
const { cleanup } = createEventPipeline({
|
||||||
sdk: props.sdk,
|
sdk: props.sdk,
|
||||||
onEvent: (directory, payload) => {
|
onEvent: (directory, payload) => {
|
||||||
handleEvent(directory, payload, childStores)
|
handleEvent(directory, payload, childStores, routingIndex)
|
||||||
},
|
},
|
||||||
onReconnect: () => {
|
onReconnect: () => {
|
||||||
for (const [dir, store] of childStores.children) {
|
for (const [dir, store] of childStores.children) {
|
||||||
@@ -546,7 +901,7 @@ export function SyncProvider(props: {
|
|||||||
if (getReconnectCandidateSessionIds(store.getState()).length === 0) continue
|
if (getReconnectCandidateSessionIds(store.getState()).length === 0) continue
|
||||||
|
|
||||||
reconnectResyncing.add(dir)
|
reconnectResyncing.add(dir)
|
||||||
void resyncDirectoryAfterReconnect(dir, store)
|
void resyncDirectoryAfterReconnect(dir, store, routingIndex)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Transient failure during resync — next SSE event or reconnect will catch up.
|
// Transient failure during resync — next SSE event or reconnect will catch up.
|
||||||
})
|
})
|
||||||
@@ -557,14 +912,15 @@ export function SyncProvider(props: {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
return cleanup
|
return cleanup
|
||||||
}, [props.sdk, childStores])
|
}, [props.sdk, childStores, routingIndex])
|
||||||
|
|
||||||
// Ensure current directory's child store exists
|
// Ensure current directory's child store exists
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.directory) {
|
if (props.directory) {
|
||||||
childStores.ensureChild(props.directory)
|
const store = childStores.ensureChild(props.directory)
|
||||||
|
ingestDirectoryStateIntoRoutingIndex(routingIndex, props.directory, store.getState())
|
||||||
}
|
}
|
||||||
}, [props.directory, childStores])
|
}, [props.directory, childStores, routingIndex])
|
||||||
|
|
||||||
// Set refs so non-React code (session-actions, session-ui-store) can access sync state
|
// Set refs so non-React code (session-actions, session-ui-store) can access sync state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user