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
262 lines
9.3 KiB
TypeScript
262 lines
9.3 KiB
TypeScript
import React from 'react';
|
|
import { getMessageQueueKey, parseMessageQueueKey, useMessageQueueStore, type MessageQueueTarget, type QueuedMessage } from '@/stores/messageQueueStore';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useSelectionStore } from '@/sync/selection-store';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { useContextStore } from '@/stores/contextStore';
|
|
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
|
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
|
import { getDirectoryState } from '@/sync/sync-refs';
|
|
import { useDirectorySync } from '@/sync/sync-context';
|
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
|
|
type SessionStatusType = 'idle' | 'busy' | 'retry';
|
|
|
|
const RECENT_ABORT_WINDOW_MS = 2000;
|
|
|
|
const AUTO_SEND_RETRY_BASE_DELAY_MS = 2000;
|
|
const AUTO_SEND_RETRY_MAX_DELAY_MS = 60000;
|
|
|
|
export type QueuedAutoSendFailure = {
|
|
messageId: string;
|
|
failures: number;
|
|
nextAttemptAt: number;
|
|
};
|
|
|
|
export const getQueuedAutoSendRetryDelayMs = (failures: number): number =>
|
|
Math.min(AUTO_SEND_RETRY_BASE_DELAY_MS * 2 ** Math.max(failures - 1, 0), AUTO_SEND_RETRY_MAX_DELAY_MS);
|
|
|
|
export const isQueuedAutoSendBackedOff = (
|
|
failure: QueuedAutoSendFailure | undefined,
|
|
messageId: string,
|
|
now: number,
|
|
): boolean => failure !== undefined && failure.messageId === messageId && now < failure.nextAttemptAt;
|
|
|
|
const hasRecentAbort = (sessionId: string): boolean => {
|
|
const abortRecord = useSessionUIStore.getState().sessionAbortFlags.get(sessionId);
|
|
if (!abortRecord) {
|
|
return false;
|
|
}
|
|
return Date.now() - abortRecord.timestamp < RECENT_ABORT_WINDOW_MS;
|
|
};
|
|
|
|
export const buildQueuedAutoSendPayload = (queue: QueuedMessage[]) => {
|
|
const queued = queue[0];
|
|
if (!queued) {
|
|
return null;
|
|
}
|
|
|
|
const agents = useConfigStore.getState().getVisibleAgents();
|
|
const { sanitizedText, mention } = parseAgentMentions(queued.content, agents);
|
|
|
|
return {
|
|
queuedMessageId: queued.id,
|
|
primaryText: sanitizedText,
|
|
primaryAttachments: queued.attachments ?? [],
|
|
agentMentionName: mention?.name,
|
|
sendConfig: queued.sendConfig,
|
|
};
|
|
};
|
|
|
|
type QueuedAutoSendPayload = NonNullable<ReturnType<typeof buildQueuedAutoSendPayload>>;
|
|
type ResolvedQueuedSendConfig = {
|
|
providerID: string;
|
|
modelID: string;
|
|
agent?: string;
|
|
variant?: string;
|
|
};
|
|
|
|
export const sendQueuedAutoSendPayload = (
|
|
sessionId: string,
|
|
directory: string,
|
|
payload: QueuedAutoSendPayload,
|
|
resolved: ResolvedQueuedSendConfig,
|
|
) => {
|
|
return useSessionUIStore.getState().sendMessage(
|
|
payload.primaryText,
|
|
resolved.providerID,
|
|
resolved.modelID,
|
|
resolved.agent,
|
|
payload.primaryAttachments,
|
|
payload.agentMentionName,
|
|
undefined,
|
|
resolved.variant,
|
|
'normal',
|
|
{ sessionId, directory },
|
|
);
|
|
};
|
|
|
|
const resolveSessionSendConfig = (sessionId: string) => {
|
|
const context = useContextStore.getState();
|
|
const config = useConfigStore.getState();
|
|
const selection = useSelectionStore.getState();
|
|
|
|
const selectedAgent =
|
|
context.getSessionAgentSelection(sessionId)
|
|
?? context.getCurrentAgent(sessionId)
|
|
?? config.currentAgentName
|
|
?? undefined;
|
|
|
|
const sessionModel = context.getSessionModelSelection(sessionId);
|
|
const agentModel = selectedAgent
|
|
? context.getAgentModelForSession(sessionId, selectedAgent)
|
|
: null;
|
|
|
|
const providerID =
|
|
agentModel?.providerId
|
|
?? sessionModel?.providerId
|
|
?? config.currentProviderId
|
|
?? selection.lastUsedProvider?.providerID;
|
|
const modelID =
|
|
agentModel?.modelId
|
|
?? sessionModel?.modelId
|
|
?? config.currentModelId
|
|
?? selection.lastUsedProvider?.modelID;
|
|
|
|
const variant =
|
|
selectedAgent && providerID && modelID
|
|
? (selection.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID)
|
|
?? context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID))
|
|
: undefined;
|
|
|
|
return {
|
|
providerID,
|
|
modelID,
|
|
agent: selectedAgent,
|
|
variant,
|
|
};
|
|
};
|
|
|
|
export const shouldDispatchQueuedAutoSend = (
|
|
previousStatusType: SessionStatusType | undefined,
|
|
currentStatusType: SessionStatusType,
|
|
hasQueuedItems: boolean = false,
|
|
): boolean => {
|
|
if (hasQueuedItems && currentStatusType === 'idle') return true;
|
|
return (previousStatusType === 'busy' || previousStatusType === 'retry')
|
|
&& currentStatusType === 'idle';
|
|
};
|
|
|
|
export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?: boolean }) {
|
|
const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (enabledOrOptions?.enabled ?? true);
|
|
const queuedMessages = useMessageQueueStore((state) => state.queuedMessages);
|
|
const autoReviewRuns = useAutoReviewStore((state) => state.runsByOriginalSessionID);
|
|
const sessionStatusRecord = useDirectorySync((state) => state.session_status);
|
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
|
|
|
const inFlightSessionsRef = React.useRef<Set<string>>(new Set());
|
|
const sendFailuresRef = React.useRef<Map<string, QueuedAutoSendFailure>>(new Map());
|
|
const previousStatusRef = React.useRef<Map<string, SessionStatusType>>(new Map());
|
|
const autoReviewBlockedSessionsRef = React.useRef<Set<string>>(new Set());
|
|
|
|
React.useEffect(() => {
|
|
if (!enabled) {
|
|
return;
|
|
}
|
|
|
|
const dispatchSessionQueue = async (target: MessageQueueTarget, queueSnapshot: QueuedMessage[]) => {
|
|
const { sessionId } = target;
|
|
const targetKey = getMessageQueueKey(target);
|
|
if (queueSnapshot.length === 0) {
|
|
return;
|
|
}
|
|
if (inFlightSessionsRef.current.has(targetKey)) {
|
|
return;
|
|
}
|
|
if (hasRecentAbort(sessionId)) {
|
|
return;
|
|
}
|
|
if (useAutoReviewStore.getState().isRunningForSession(sessionId)) {
|
|
autoReviewBlockedSessionsRef.current.add(sessionId);
|
|
return;
|
|
}
|
|
|
|
const currentStatus = getDirectoryState(target.directory)?.session_status?.[sessionId]?.type ?? 'idle';
|
|
if (currentStatus !== 'idle') {
|
|
return;
|
|
}
|
|
|
|
const payload = buildQueuedAutoSendPayload(queueSnapshot);
|
|
if (!payload) {
|
|
return;
|
|
}
|
|
|
|
const failure = sendFailuresRef.current.get(targetKey);
|
|
if (failure && failure.messageId !== payload.queuedMessageId) {
|
|
sendFailuresRef.current.delete(targetKey);
|
|
} else if (isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) {
|
|
return;
|
|
}
|
|
|
|
// Use send config captured at queue time; fall back to current config
|
|
const captured = payload.sendConfig;
|
|
const resolved = captured?.providerID && captured?.modelID
|
|
? captured
|
|
: resolveSessionSendConfig(sessionId);
|
|
if (!resolved.providerID || !resolved.modelID) {
|
|
return;
|
|
}
|
|
|
|
inFlightSessionsRef.current.add(targetKey);
|
|
|
|
try {
|
|
await sendQueuedAutoSendPayload(sessionId, target.directory, payload, {
|
|
providerID: resolved.providerID,
|
|
modelID: resolved.modelID,
|
|
agent: resolved.agent,
|
|
variant: resolved.variant,
|
|
});
|
|
useMessageQueueStore.getState().removeFromQueue(target, payload.queuedMessageId);
|
|
sendFailuresRef.current.delete(targetKey);
|
|
} catch (error) {
|
|
console.warn('[queue] queued auto-send failed:', error);
|
|
const priorFailures = failure?.messageId === payload.queuedMessageId ? failure.failures : 0;
|
|
const failures = priorFailures + 1;
|
|
sendFailuresRef.current.set(targetKey, {
|
|
messageId: payload.queuedMessageId,
|
|
failures,
|
|
nextAttemptAt: Date.now() + getQueuedAutoSendRetryDelayMs(failures),
|
|
});
|
|
} finally {
|
|
inFlightSessionsRef.current.delete(targetKey);
|
|
}
|
|
};
|
|
|
|
const statusRecord = sessionStatusRecord ?? {};
|
|
const nextStatusMap = new Map(previousStatusRef.current);
|
|
for (const [sessionId, status] of Object.entries(statusRecord)) {
|
|
if (status) {
|
|
nextStatusMap.set(sessionId, status.type as SessionStatusType);
|
|
}
|
|
}
|
|
|
|
const queueEntries = Object.entries(queuedMessages);
|
|
queueEntries.forEach(([key, queue]) => {
|
|
const target = parseMessageQueueKey(key);
|
|
if (!target || target.runtimeKey !== getRuntimeKey() || target.directory !== currentDirectory) return;
|
|
const { sessionId } = target;
|
|
const currentStatusType = (statusRecord[sessionId]?.type ?? 'idle') as SessionStatusType;
|
|
const previousStatusType = previousStatusRef.current.get(sessionId);
|
|
const wasAutoReviewBlocked = autoReviewBlockedSessionsRef.current.has(sessionId);
|
|
const isAutoReviewRunning = useAutoReviewStore.getState().isRunningForSession(sessionId);
|
|
if (isAutoReviewRunning) {
|
|
autoReviewBlockedSessionsRef.current.add(sessionId);
|
|
} else if (wasAutoReviewBlocked) {
|
|
autoReviewBlockedSessionsRef.current.delete(sessionId);
|
|
}
|
|
|
|
if (queue.length > 0 && (
|
|
shouldDispatchQueuedAutoSend(previousStatusType, currentStatusType, queue.length > 0)
|
|
|| (wasAutoReviewBlocked && !isAutoReviewRunning && currentStatusType === 'idle')
|
|
)) {
|
|
void dispatchSessionQueue(target, queue);
|
|
}
|
|
|
|
nextStatusMap.set(sessionId, currentStatusType);
|
|
});
|
|
|
|
previousStatusRef.current = nextStatusMap;
|
|
}, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory]);
|
|
}
|