Input history (#3035) shipped with "All projects" as the default scope and only recorded prompts sent after the upgrade, so ArrowUp showed other sessions' prompts and, once switched to "Current session", nothing at all. Default to the current session and merge the visible transcript's prompts with the persisted bucket. Existing sessions recall as they did before #3035, while new prompts keep their attachments and stay recallable after a revert hides them from the transcript. Cleanup across #1855, #2297, #3072, #3178, #3035 and #3135: drop the duplicate poll guards in the file content poller, the zod schema the VS Code package cannot depend on, a copied file-URL helper and stray whitespace; move the Enter-to-send strings into the settings namespace; document OPENCHAMBER_CHATS_DIR, resolve the chats root once on the server and warm it alongside the other bootstrap calls.
367 lines
14 KiB
TypeScript
367 lines
14 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 { queuedContextToParts } from '@/components/chat/composer/submit/buildOutgoingMessage';
|
|
import { getDirectoryState } from '@/sync/sync-refs';
|
|
import { useDirectorySync } from '@/sync/sync-context';
|
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
import { createInputHistorySubmission } from '@/stores/useInputHistoryStore';
|
|
|
|
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;
|
|
|
|
export const createQueuedAutoSendRetryScheduler = (
|
|
onWake: () => void,
|
|
now: () => number = Date.now,
|
|
scheduleTimeout: (callback: () => void, delay: number) => ReturnType<typeof setTimeout> = setTimeout,
|
|
cancelTimeout: (timer: ReturnType<typeof setTimeout>) => void = clearTimeout,
|
|
) => {
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
let scheduledAt: number | null = null;
|
|
|
|
return {
|
|
schedule(retryAt: number) {
|
|
if (scheduledAt !== null && scheduledAt <= retryAt) return;
|
|
if (timer !== null) cancelTimeout(timer);
|
|
scheduledAt = retryAt;
|
|
timer = scheduleTimeout(() => {
|
|
timer = null;
|
|
scheduledAt = null;
|
|
onWake();
|
|
}, Math.max(0, retryAt - now()));
|
|
},
|
|
dispose() {
|
|
if (timer !== null) cancelTimeout(timer);
|
|
timer = null;
|
|
scheduledAt = null;
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* When the abort window is still open, returns the time it expires so the
|
|
* caller can wake the queue then. Returns `null` once sending is allowed
|
|
* again — a queued item must not wait for an unrelated state change to be
|
|
* retried after the window closes.
|
|
*/
|
|
const getAbortHoldUntil = (sessionId: string): number | null => {
|
|
const abortRecord = useSessionUIStore.getState().sessionAbortFlags.get(sessionId);
|
|
if (!abortRecord) {
|
|
return null;
|
|
}
|
|
const holdUntil = abortRecord.timestamp + RECENT_ABORT_WINDOW_MS;
|
|
return Date.now() < holdUntil ? holdUntil : null;
|
|
};
|
|
|
|
export const buildQueuedAutoSendPayload = (queue: QueuedMessage[]) => {
|
|
const queued = queue[0];
|
|
if (!queued) {
|
|
return null;
|
|
}
|
|
|
|
// A queued message is delivered as captured: mention already stripped,
|
|
// file mentions resolved, and the context it was queued with following it.
|
|
return {
|
|
queuedMessageId: queued.id,
|
|
historySubmissions: [createInputHistorySubmission(queued.content, queued.attachments ?? [])],
|
|
primaryText: queued.text,
|
|
primaryAttachments: queued.attachments ?? [],
|
|
agentMentionName: queued.agentMention,
|
|
additionalParts: queuedContextToParts(queued.context ?? []),
|
|
sendConfig: queued.sendConfig,
|
|
};
|
|
};
|
|
|
|
type QueuedAutoSendPayload = NonNullable<ReturnType<typeof buildQueuedAutoSendPayload>>;
|
|
type ResolvedQueuedSendConfig = {
|
|
providerID: string;
|
|
modelID: string;
|
|
agent?: string;
|
|
variant?: string;
|
|
};
|
|
|
|
export const sendQueuedAutoSendPayload = (
|
|
target: MessageQueueTarget,
|
|
payload: QueuedAutoSendPayload,
|
|
resolved: ResolvedQueuedSendConfig,
|
|
) => {
|
|
return useSessionUIStore.getState().sendMessage(
|
|
payload.primaryText,
|
|
resolved.providerID,
|
|
resolved.modelID,
|
|
resolved.agent,
|
|
payload.primaryAttachments,
|
|
payload.agentMentionName,
|
|
payload.additionalParts.length > 0 ? payload.additionalParts : undefined,
|
|
resolved.variant,
|
|
'normal',
|
|
{ target, historySubmissions: payload.historySubmissions },
|
|
);
|
|
};
|
|
|
|
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;
|
|
|
|
// A recorded `null` is an explicit "Default": it stops the lookup and sends
|
|
// no effort, instead of falling through to the persisted copy.
|
|
const savedVariant =
|
|
selectedAgent && providerID && modelID
|
|
? (() => {
|
|
const live = selection.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID);
|
|
return live !== undefined
|
|
? live
|
|
: context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID);
|
|
})()
|
|
: undefined;
|
|
const variant = savedVariant ?? 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';
|
|
};
|
|
|
|
/**
|
|
* Resolve the live status the queue gate should honor for a session.
|
|
*
|
|
* The server's `/session/status` map only lists busy/retry sessions — idle
|
|
* sessions are absent — so a missing entry means "idle per the snapshot", not
|
|
* "no information". A missed busy event therefore leaves no entry while a turn
|
|
* is still streaming. The trailing in-flight assistant message is the live
|
|
* evidence of that running turn: treat it as busy so the queue never dispatches
|
|
* into it (mirrors `useSessionActivity`'s fallback). The entry becomes idle the
|
|
* moment the message completes or an idle status event lands. This reads the
|
|
* directory child store directly so both the effect-loop gate and the
|
|
* dispatch-time re-check agree.
|
|
*/
|
|
export const resolveQueuedSessionStatusType = (
|
|
sessionId: string,
|
|
directory: string,
|
|
): SessionStatusType => {
|
|
const state = getDirectoryState(directory);
|
|
const statusType = state?.session_status?.[sessionId]?.type;
|
|
if (statusType === 'busy' || statusType === 'retry') {
|
|
return statusType;
|
|
}
|
|
const sessionMessages = state?.message?.[sessionId];
|
|
const lastMessage = sessionMessages && sessionMessages.length > 0
|
|
? sessionMessages[sessionMessages.length - 1]
|
|
: undefined;
|
|
if (
|
|
lastMessage?.role === 'assistant'
|
|
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number'
|
|
) {
|
|
return 'busy';
|
|
}
|
|
return '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);
|
|
// Message completion clears the in-flight fallback in
|
|
// resolveQueuedSessionStatusType; subscribe so the queue drains the moment
|
|
// the trailing assistant message completes even if status events were missed.
|
|
const sessionMessages = useDirectorySync((state) => state.message);
|
|
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());
|
|
const [retryTick, setRetryTick] = React.useState(0);
|
|
const retryScheduler = React.useMemo(
|
|
() => createQueuedAutoSendRetryScheduler(() => setRetryTick((value) => value + 1)),
|
|
[],
|
|
);
|
|
|
|
React.useEffect(() => () => retryScheduler.dispose(), [retryScheduler]);
|
|
|
|
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;
|
|
}
|
|
const abortHoldUntil = getAbortHoldUntil(sessionId);
|
|
if (abortHoldUntil !== null) {
|
|
retryScheduler.schedule(abortHoldUntil);
|
|
return;
|
|
}
|
|
if (useAutoReviewStore.getState().isRunningForSession(sessionId)) {
|
|
autoReviewBlockedSessionsRef.current.add(sessionId);
|
|
return;
|
|
}
|
|
|
|
const currentStatus = resolveQueuedSessionStatusType(sessionId, target.directory);
|
|
if (currentStatus !== 'idle') {
|
|
return;
|
|
}
|
|
|
|
// Read the queue back at dispatch time and skip anything already being
|
|
// delivered, rather than trusting the render-time snapshot.
|
|
const payload = buildQueuedAutoSendPayload(useMessageQueueStore.getState().getSendableQueue(target));
|
|
if (!payload) {
|
|
return;
|
|
}
|
|
|
|
const failure = sendFailuresRef.current.get(targetKey);
|
|
if (failure && failure.messageId !== payload.queuedMessageId) {
|
|
sendFailuresRef.current.delete(targetKey);
|
|
} else if (failure && isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) {
|
|
retryScheduler.schedule(failure.nextAttemptAt);
|
|
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) {
|
|
// Legacy queues may predate captured send configuration. Config
|
|
// hydration is asynchronous, so retry instead of stranding the item
|
|
// until an unrelated status or directory update happens.
|
|
retryScheduler.schedule(Date.now() + AUTO_SEND_RETRY_BASE_DELAY_MS);
|
|
return;
|
|
}
|
|
|
|
inFlightSessionsRef.current.add(targetKey);
|
|
// The ref only guards this hook. Publish the dispatch to the store so the
|
|
// composer cannot merge the same item into a parallel send while this one
|
|
// is still awaiting the server.
|
|
useMessageQueueStore.getState().markSending(target, payload.queuedMessageId);
|
|
|
|
try {
|
|
await sendQueuedAutoSendPayload(target, 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;
|
|
const nextAttemptAt = Date.now() + getQueuedAutoSendRetryDelayMs(failures);
|
|
sendFailuresRef.current.set(targetKey, {
|
|
messageId: payload.queuedMessageId,
|
|
failures,
|
|
nextAttemptAt,
|
|
});
|
|
retryScheduler.schedule(nextAttemptAt);
|
|
} finally {
|
|
inFlightSessionsRef.current.delete(targetKey);
|
|
useMessageQueueStore.getState().clearSending(target, payload.queuedMessageId);
|
|
}
|
|
};
|
|
|
|
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 = resolveQueuedSessionStatusType(sessionId, target.directory);
|
|
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, sessionMessages, autoReviewRuns, currentDirectory, retryTick, retryScheduler]);
|
|
}
|