mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-08 06:32:44 +08:00
fix(chat): make message queuing reliable across sessions and turn boundaries
Queued messages had four related defects: - A queued message flashed and then vanished at flush time: concurrent transcript refreshes (the `complete` handler racing the watcher-triggered update) could resolve out of order, letting a stale response overwrite newer server messages after the optimistic row was pruned. Session slots now carry a monotonic fetch ticket and discard stale fetch/refresh/ fetchMore responses. - Switching sessions flushed the previous session's queued draft into the newly viewed one (sending it with the wrong provider's settings, e.g. a Claude model into a Codex session). The composer flush is now scoped to its session, and a draft restored into an idle session sends after a short grace period so a live-run ack can cancel it. - The thinking banner never appeared for a queued turn: the chat handler's session-keyed completeRun safety net could fire after a queued message had already started the session's next run, emitting a spurious `complete` that killed it. The safety net is now scoped to its own run via completeRunIfCurrent (with a regression test). - Queued messages only sent while their session was being viewed. Drafts now persist their send options (model, effort, permissions) at queue time, and a new app-level useQueuedMessageAutoSend hook dispatches a non-viewed session's queued message as soon as its run completes, using the storage key as the claim ticket to prevent double sends.
This commit is contained in:
@@ -318,6 +318,22 @@ export const chatRunRegistry = {
|
||||
run.writer.sendComplete(opts);
|
||||
},
|
||||
|
||||
/**
|
||||
* Safety-net variant of `completeRun` scoped to one specific run: a no-op
|
||||
* unless `run` is still the session's current, running run. A runtime
|
||||
* promise can resolve after its own `complete` already streamed AND a new
|
||||
* run has replaced it in the registry (a queued message sends within
|
||||
* milliseconds of the previous turn ending) — the session-keyed
|
||||
* `completeRun` would terminate that newer run.
|
||||
*/
|
||||
completeRunIfCurrent(run: ChatRun, opts: { exitCode: number; aborted?: boolean }): void {
|
||||
if (runs.get(run.appSessionId) !== run || run.status !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
run.writer.sendComplete(opts);
|
||||
},
|
||||
|
||||
/**
|
||||
* Test-only escape hatch: clears every tracked run.
|
||||
*/
|
||||
|
||||
@@ -212,8 +212,10 @@ async function handleChatSend(
|
||||
} finally {
|
||||
// Safety net: a runtime that crashed (or resolved) without emitting its
|
||||
// terminal `complete` would otherwise leave the session stuck in
|
||||
// "processing" forever on every connected client.
|
||||
chatRunRegistry.completeRun(sessionId, { exitCode: 1 });
|
||||
// "processing" forever on every connected client. Scoped to THIS run —
|
||||
// a queued message can start the session's next run before this promise
|
||||
// settles, and the session-keyed completeRun would kill that new run.
|
||||
chatRunRegistry.completeRunIfCurrent(run, { exitCode: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,44 @@ test('complete marks the run finished and duplicate completes are dropped', asyn
|
||||
});
|
||||
});
|
||||
|
||||
test('a finished run\'s safety net cannot complete the session\'s next run', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-9', 'codex', '/workspace/demo');
|
||||
const connection = new FakeConnection();
|
||||
|
||||
const firstRun = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-9',
|
||||
provider: 'codex',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(firstRun);
|
||||
firstRun.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-9', exitCode: 0 });
|
||||
|
||||
// A queued message starts the next run before the first run's runtime
|
||||
// promise settles (the chat handler's `finally` hasn't executed yet).
|
||||
const secondRun = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-9',
|
||||
provider: 'codex',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(secondRun);
|
||||
|
||||
// First run's safety net fires late: it must not touch the new run.
|
||||
chatRunRegistry.completeRunIfCurrent(firstRun, { exitCode: 1 });
|
||||
assert.equal(chatRunRegistry.isProcessing('app-run-9'), true);
|
||||
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1);
|
||||
|
||||
// The second run's own safety net still works while it is current.
|
||||
chatRunRegistry.completeRunIfCurrent(secondRun, { exitCode: 1 });
|
||||
assert.equal(chatRunRegistry.isProcessing('app-run-9'), false);
|
||||
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('listRunningRuns returns only currently running app sessions', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo');
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/Palett
|
||||
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
||||
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
||||
import { useProjectsState } from '../../hooks/useProjectsState';
|
||||
import { useQueuedMessageAutoSend } from '../../hooks/useQueuedMessageAutoSend';
|
||||
import { api } from '../../utils/api';
|
||||
|
||||
type RunningSessionApiItem = {
|
||||
@@ -84,6 +85,17 @@ function AppContentInner() {
|
||||
activeSessions: processingSessions,
|
||||
});
|
||||
|
||||
// Queued messages for sessions that finish while another session (or none)
|
||||
// is being viewed are sent from here; the viewed session's composer handles
|
||||
// its own queue.
|
||||
useQueuedMessageAutoSend({
|
||||
processingSessions,
|
||||
activeSessionId: selectedSession?.id ?? sessionId ?? null,
|
||||
ws,
|
||||
sendMessage,
|
||||
markSessionProcessing,
|
||||
});
|
||||
|
||||
const refreshRunningSessions = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.runningSessions();
|
||||
|
||||
@@ -14,7 +14,13 @@ import { useDropzone } from 'react-dropzone';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
||||
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
||||
import { safeLocalStorage } from '../utils/chatStorage';
|
||||
import {
|
||||
clearQueuedMessage,
|
||||
readQueuedMessage,
|
||||
safeLocalStorage,
|
||||
writeQueuedMessage,
|
||||
type QueuedSendOptions,
|
||||
} from '../utils/chatStorage';
|
||||
import type {
|
||||
ChatMessage,
|
||||
PendingPermissionRequest,
|
||||
@@ -147,6 +153,18 @@ const createFakeSubmitEvent = () => {
|
||||
export type QueuedDraft = {
|
||||
content: string;
|
||||
images: File[];
|
||||
/**
|
||||
* Send options snapshotted at queue time. Persisted with the draft so the
|
||||
* app-level auto-send can dispatch the message with the right model and
|
||||
* permission settings while another session is being viewed.
|
||||
*/
|
||||
options?: QueuedSendOptions;
|
||||
};
|
||||
|
||||
const restoreQueuedDraft = (sessionKey: string): QueuedDraft | null => {
|
||||
const saved = readQueuedMessage(sessionKey);
|
||||
// Image attachments can't survive a reload; only text and options persist.
|
||||
return saved ? { content: saved.content, images: [], options: saved.options } : null;
|
||||
};
|
||||
|
||||
const getNotificationSessionSummary = (
|
||||
@@ -227,9 +245,13 @@ export function useChatComposerState({
|
||||
if (typeof window === 'undefined' || !sessionKey) {
|
||||
return null;
|
||||
}
|
||||
const saved = safeLocalStorage.getItem(`queued_message_${sessionKey}`);
|
||||
return saved ? { content: saved, images: [] } : null;
|
||||
return restoreQueuedDraft(sessionKey);
|
||||
});
|
||||
// Which session the in-memory `queuedDraft` belongs to. On a session switch
|
||||
// there is one commit where `sessionKey` already points at the new session
|
||||
// while `queuedDraft` still holds the old session's draft; the persistence
|
||||
// effect must not write across that gap.
|
||||
const queuedDraftSessionRef = useRef<string | null>(sessionKey);
|
||||
|
||||
const handleBuiltInCommand = useCallback(
|
||||
(result: CommandExecutionResult) => {
|
||||
@@ -561,6 +583,66 @@ export function useChatComposerState({
|
||||
noKeyboard: true,
|
||||
});
|
||||
|
||||
// Snapshot of everything `chat.send` needs beyond the text itself. Built at
|
||||
// send time for immediate sends and at queue time for queued ones, so a
|
||||
// queued message keeps the provider settings it was composed under even if
|
||||
// it is later dispatched outside this composer (app-level auto-send).
|
||||
const buildSendOptions = useCallback((currentInput: string): QueuedSendOptions => {
|
||||
const getToolsSettings = () => {
|
||||
try {
|
||||
const settingsKey =
|
||||
provider === 'cursor'
|
||||
? 'cursor-tools-settings'
|
||||
: provider === 'codex'
|
||||
? 'codex-settings'
|
||||
: provider === 'opencode'
|
||||
? 'opencode-settings'
|
||||
: 'claude-settings';
|
||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||
if (savedSettings) {
|
||||
return JSON.parse(savedSettings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading tools settings:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false,
|
||||
};
|
||||
};
|
||||
|
||||
const toolsSettings = getToolsSettings();
|
||||
const model =
|
||||
provider === 'cursor'
|
||||
? cursorModel
|
||||
: provider === 'codex'
|
||||
? codexModel
|
||||
: provider === 'opencode'
|
||||
? opencodeModel
|
||||
: claudeModel;
|
||||
|
||||
return {
|
||||
model,
|
||||
effort: currentProviderEffort,
|
||||
permissionMode: resolvePermissionModeForProvider(provider, permissionMode),
|
||||
toolsSettings,
|
||||
skipPermissions: toolsSettings?.skipPermissions || false,
|
||||
sessionSummary: getNotificationSessionSummary(selectedSession, currentInput),
|
||||
};
|
||||
}, [
|
||||
claudeModel,
|
||||
codexModel,
|
||||
currentProviderEffort,
|
||||
cursorModel,
|
||||
opencodeModel,
|
||||
permissionMode,
|
||||
provider,
|
||||
resolvePermissionModeForProvider,
|
||||
selectedSession,
|
||||
]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (
|
||||
event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>,
|
||||
@@ -575,7 +657,12 @@ export function useChatComposerState({
|
||||
// It's auto-flushed (re-running this same function) once the turn ends,
|
||||
// so it still goes through slash-command interception, image upload, etc.
|
||||
if (isLoading) {
|
||||
setQueuedDraft({ content: currentInput, images: attachedImages });
|
||||
queuedDraftSessionRef.current = sessionKey;
|
||||
setQueuedDraft({
|
||||
content: currentInput,
|
||||
images: attachedImages,
|
||||
options: buildSendOptions(currentInput),
|
||||
});
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
setAttachedImages([]);
|
||||
@@ -728,42 +815,6 @@ export function useChatComposerState({
|
||||
setIsUserScrolledUp(false);
|
||||
setTimeout(() => scrollToBottom(), 100);
|
||||
|
||||
const getToolsSettings = () => {
|
||||
try {
|
||||
const settingsKey =
|
||||
provider === 'cursor'
|
||||
? 'cursor-tools-settings'
|
||||
: provider === 'codex'
|
||||
? 'codex-settings'
|
||||
: provider === 'opencode'
|
||||
? 'opencode-settings'
|
||||
: 'claude-settings';
|
||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||
if (savedSettings) {
|
||||
return JSON.parse(savedSettings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading tools settings:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false,
|
||||
};
|
||||
};
|
||||
|
||||
const toolsSettings = getToolsSettings();
|
||||
const model =
|
||||
provider === 'cursor'
|
||||
? cursorModel
|
||||
: provider === 'codex'
|
||||
? codexModel
|
||||
: provider === 'opencode'
|
||||
? opencodeModel
|
||||
: claudeModel;
|
||||
const effort = currentProviderEffort;
|
||||
|
||||
// One message shape for every provider. The backend resolves the
|
||||
// provider, project path, and provider-native resume id from the
|
||||
// session row; `options` only carries composer-level preferences.
|
||||
@@ -772,12 +823,7 @@ export function useChatComposerState({
|
||||
sessionId: targetSessionId,
|
||||
content: messageContent,
|
||||
options: {
|
||||
model,
|
||||
effort,
|
||||
permissionMode: resolvePermissionModeForProvider(provider, permissionMode),
|
||||
toolsSettings,
|
||||
skipPermissions: toolsSettings?.skipPermissions || false,
|
||||
sessionSummary,
|
||||
...buildSendOptions(messageContent),
|
||||
images: uploadedImages,
|
||||
},
|
||||
});
|
||||
@@ -799,23 +845,18 @@ export function useChatComposerState({
|
||||
[
|
||||
selectedSession,
|
||||
attachedImages,
|
||||
claudeModel,
|
||||
codexModel,
|
||||
currentProviderEffort,
|
||||
buildSendOptions,
|
||||
currentSessionId,
|
||||
cursorModel,
|
||||
executeCommand,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
onSessionProcessing,
|
||||
onSessionEstablished,
|
||||
permissionMode,
|
||||
provider,
|
||||
resolvePermissionModeForProvider,
|
||||
resetCommandMenuState,
|
||||
scrollToBottom,
|
||||
selectedProject,
|
||||
sendMessage,
|
||||
sessionKey,
|
||||
addMessage,
|
||||
setIsUserScrolledUp,
|
||||
slashCommands,
|
||||
@@ -829,21 +870,47 @@ export function useChatComposerState({
|
||||
// Once the in-flight turn ends, replay the queued draft through the normal
|
||||
// submit path (slash commands, image upload, etc. all still apply).
|
||||
const wasLoadingRef = useRef(isLoading);
|
||||
const flushSessionKeyRef = useRef(sessionKey);
|
||||
useEffect(() => {
|
||||
const wasLoading = wasLoadingRef.current;
|
||||
wasLoadingRef.current = isLoading;
|
||||
if (!wasLoading || isLoading || !queuedDraft) {
|
||||
|
||||
// A session switch changes which session `isLoading` describes, so this
|
||||
// transition says nothing about the queued draft's own session. Never
|
||||
// flush across it — the swap effect below replaces `queuedDraft` with the
|
||||
// new session's saved draft right after this.
|
||||
if (flushSessionKeyRef.current !== sessionKey) {
|
||||
flushSessionKeyRef.current = sessionKey;
|
||||
return;
|
||||
}
|
||||
|
||||
setQueuedDraft(null);
|
||||
setInput(queuedDraft.content);
|
||||
inputValueRef.current = queuedDraft.content;
|
||||
setAttachedImages(queuedDraft.images);
|
||||
setTimeout(() => {
|
||||
handleSubmitRef.current?.(createFakeSubmitEvent());
|
||||
}, 0);
|
||||
}, [isLoading, queuedDraft]);
|
||||
if (isLoading || !queuedDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Turn just ended in this session: flush immediately. Otherwise this is a
|
||||
// saved draft restored into an apparently idle session — hold it briefly
|
||||
// so the `chat_subscribed` ack can flip `isLoading` if a run is actually
|
||||
// still live (the cleanup below cancels the send in that case).
|
||||
const delay = wasLoading ? 0 : 750;
|
||||
const timer = setTimeout(() => {
|
||||
// The saved key is the claim ticket shared with the app-level auto-send
|
||||
// (which handles sessions that finish while not viewed). If it's gone,
|
||||
// the message was already dispatched — don't send it twice.
|
||||
if (sessionKey && !readQueuedMessage(sessionKey)) {
|
||||
setQueuedDraft(null);
|
||||
return;
|
||||
}
|
||||
setQueuedDraft(null);
|
||||
setInput(queuedDraft.content);
|
||||
inputValueRef.current = queuedDraft.content;
|
||||
setAttachedImages(queuedDraft.images);
|
||||
setTimeout(() => {
|
||||
handleSubmitRef.current?.(createFakeSubmitEvent());
|
||||
}, 0);
|
||||
}, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isLoading, queuedDraft, sessionKey, setInput]);
|
||||
|
||||
const editQueuedDraft = useCallback(() => {
|
||||
if (!queuedDraft) {
|
||||
@@ -898,28 +965,33 @@ export function useChatComposerState({
|
||||
}
|
||||
}, [input, selectedProjectId]);
|
||||
|
||||
// Switching sessions swaps in that session's queued draft (image
|
||||
// attachments can't survive a reload, so only the text is restored).
|
||||
// Persist the queued draft under its session's key. Must be defined BEFORE
|
||||
// the swap effect below: on a session switch there is one commit where
|
||||
// `sessionKey` already points at the new session while `queuedDraft` (and
|
||||
// the owner ref) still describe the old one — the ref mismatch makes this
|
||||
// effect skip that commit instead of writing/clearing across sessions.
|
||||
useEffect(() => {
|
||||
if (!sessionKey || queuedDraftSessionRef.current !== sessionKey) {
|
||||
return;
|
||||
}
|
||||
if (queuedDraft?.content) {
|
||||
writeQueuedMessage(sessionKey, { content: queuedDraft.content, options: queuedDraft.options });
|
||||
} else {
|
||||
clearQueuedMessage(sessionKey);
|
||||
}
|
||||
}, [queuedDraft, sessionKey]);
|
||||
|
||||
// Switching sessions swaps in that session's queued draft (image
|
||||
// attachments can't survive a reload, so only text and options restore).
|
||||
useEffect(() => {
|
||||
queuedDraftSessionRef.current = sessionKey;
|
||||
if (!sessionKey) {
|
||||
setQueuedDraft(null);
|
||||
return;
|
||||
}
|
||||
const saved = safeLocalStorage.getItem(`queued_message_${sessionKey}`);
|
||||
setQueuedDraft(saved ? { content: saved, images: [] } : null);
|
||||
setQueuedDraft(restoreQueuedDraft(sessionKey));
|
||||
}, [sessionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
if (queuedDraft?.content) {
|
||||
safeLocalStorage.setItem(`queued_message_${sessionKey}`, queuedDraft.content);
|
||||
} else {
|
||||
safeLocalStorage.removeItem(`queued_message_${sessionKey}`);
|
||||
}
|
||||
}, [queuedDraft, sessionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current) {
|
||||
return;
|
||||
|
||||
@@ -43,6 +43,52 @@ export const safeLocalStorage = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Composer options captured when a message is queued, so the message can be
|
||||
* sent later with the exact settings (model, permission mode, tools) the
|
||||
* session's composer had at queue time — even from outside the composer,
|
||||
* e.g. the app-level auto-send that fires while another session is viewed.
|
||||
*/
|
||||
export type QueuedSendOptions = Record<string, unknown>;
|
||||
|
||||
export type StoredQueuedMessage = {
|
||||
content: string;
|
||||
options?: QueuedSendOptions;
|
||||
};
|
||||
|
||||
export const queuedMessageKey = (sessionId: string) => `queued_message_${sessionId}`;
|
||||
|
||||
/**
|
||||
* Reads a session's queued message. Understands both the JSON
|
||||
* `{ content, options }` format and the legacy raw-text format.
|
||||
*/
|
||||
export function readQueuedMessage(sessionId: string): StoredQueuedMessage | null {
|
||||
const raw = safeLocalStorage.getItem(queuedMessageKey(sessionId));
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && typeof (parsed as StoredQueuedMessage).content === 'string') {
|
||||
const { content, options } = parsed as StoredQueuedMessage;
|
||||
return content.trim() ? { content, options } : null;
|
||||
}
|
||||
} catch {
|
||||
// Legacy format: the raw draft text itself.
|
||||
}
|
||||
|
||||
return raw.trim() ? { content: raw } : null;
|
||||
}
|
||||
|
||||
export function writeQueuedMessage(sessionId: string, message: StoredQueuedMessage): void {
|
||||
safeLocalStorage.setItem(queuedMessageKey(sessionId), JSON.stringify(message));
|
||||
}
|
||||
|
||||
export function clearQueuedMessage(sessionId: string): void {
|
||||
safeLocalStorage.removeItem(queuedMessageKey(sessionId));
|
||||
}
|
||||
|
||||
export function getClaudeSettings(): ClaudeSettings {
|
||||
const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY);
|
||||
if (!raw) {
|
||||
|
||||
70
src/hooks/useQueuedMessageAutoSend.ts
Normal file
70
src/hooks/useQueuedMessageAutoSend.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { clearQueuedMessage, readQueuedMessage } from '../components/chat/utils/chatStorage';
|
||||
|
||||
import type { MarkSessionProcessing, SessionActivityMap } from './useSessionProtection';
|
||||
|
||||
interface UseQueuedMessageAutoSendArgs {
|
||||
processingSessions: SessionActivityMap;
|
||||
/**
|
||||
* The session currently open in the chat view. Its queued draft is owned by
|
||||
* the composer (which also handles image attachments and slash commands),
|
||||
* so this hook never touches it.
|
||||
*/
|
||||
activeSessionId: string | null;
|
||||
ws: WebSocket | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
markSessionProcessing: MarkSessionProcessing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches queued messages for sessions the user is NOT currently viewing.
|
||||
*
|
||||
* The composer persists each queued draft (text + send options snapshotted at
|
||||
* queue time) under `queued_message_<sessionId>`. When a session's run leaves
|
||||
* the processing map — its previous response completed — this hook sends that
|
||||
* session's queued message immediately instead of waiting for the user to
|
||||
* open the session again. Removing the storage key before sending is the
|
||||
* claim that keeps the composer's own flush from double-sending.
|
||||
*/
|
||||
export function useQueuedMessageAutoSend({
|
||||
processingSessions,
|
||||
activeSessionId,
|
||||
ws,
|
||||
sendMessage,
|
||||
markSessionProcessing,
|
||||
}: UseQueuedMessageAutoSendArgs) {
|
||||
const prevProcessingRef = useRef<ReadonlySet<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevProcessingRef.current;
|
||||
const current = new Set(processingSessions.keys());
|
||||
prevProcessingRef.current = current;
|
||||
|
||||
for (const sessionId of prev) {
|
||||
if (current.has(sessionId) || sessionId === activeSessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queued = readQueuedMessage(sessionId);
|
||||
if (!queued) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A closed socket would drop the send silently; keep the draft so the
|
||||
// composer (or a later completion) can retry once we're connected.
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
continue;
|
||||
}
|
||||
|
||||
clearQueuedMessage(sessionId);
|
||||
sendMessage({
|
||||
type: 'chat.send',
|
||||
sessionId,
|
||||
content: queued.content,
|
||||
options: { ...(queued.options ?? {}), images: [] },
|
||||
});
|
||||
markSessionProcessing(sessionId, { statusText: null, canInterrupt: true });
|
||||
}
|
||||
}, [processingSessions, activeSessionId, ws, sendMessage, markSessionProcessing]);
|
||||
}
|
||||
@@ -97,6 +97,16 @@ export interface SessionSlot {
|
||||
/** @internal Cache-invalidation refs for computeMerged */
|
||||
_lastServerRef: NormalizedMessage[];
|
||||
_lastRealtimeRef: NormalizedMessage[];
|
||||
/**
|
||||
* @internal Monotonic ticket per server fetch (fetch/refresh/fetchMore) and
|
||||
* the ticket of the last response applied. Concurrent fetches for the same
|
||||
* session can resolve out of order — e.g. the `complete` refresh racing the
|
||||
* watcher-triggered refresh right as a queued message is flushed — and a
|
||||
* stale response applied last would wind `serverMessages` back to a
|
||||
* transcript that no longer matches what the user already saw.
|
||||
*/
|
||||
_fetchSeq: number;
|
||||
_appliedFetchSeq: number;
|
||||
status: SessionStatus;
|
||||
fetchedAt: number;
|
||||
total: number;
|
||||
@@ -120,6 +130,8 @@ function createEmptySlot(): SessionSlot {
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
tokenUsage: null,
|
||||
_fetchSeq: 0,
|
||||
_appliedFetchSeq: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -459,6 +471,7 @@ export function useSessionStore() {
|
||||
} = {},
|
||||
) => {
|
||||
const slot = getSlot(sessionId);
|
||||
const fetchTicket = ++slot._fetchSeq;
|
||||
slot.status = 'loading';
|
||||
notify(sessionId);
|
||||
|
||||
@@ -481,6 +494,12 @@ export function useSessionStore() {
|
||||
const data = body?.data ?? body;
|
||||
const messages: NormalizedMessage[] = data.messages || [];
|
||||
|
||||
// A later-started fetch already applied: this response is stale.
|
||||
if (fetchTicket <= slot._appliedFetchSeq) {
|
||||
return slot;
|
||||
}
|
||||
slot._appliedFetchSeq = fetchTicket;
|
||||
|
||||
slot.serverMessages = messages;
|
||||
slot.total = data.total ?? messages.length;
|
||||
slot.hasMore = Boolean(data.hasMore);
|
||||
@@ -496,8 +515,11 @@ export function useSessionStore() {
|
||||
return slot;
|
||||
} catch (error) {
|
||||
console.error(`[SessionStore] fetch failed for ${sessionId}:`, error);
|
||||
slot.status = 'error';
|
||||
notify(sessionId);
|
||||
// Don't clobber a newer fetch's result with a stale failure.
|
||||
if (fetchTicket > slot._appliedFetchSeq) {
|
||||
slot.status = 'error';
|
||||
notify(sessionId);
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
}, [getSlot, notify]);
|
||||
@@ -514,6 +536,7 @@ export function useSessionStore() {
|
||||
const slot = getSlot(sessionId);
|
||||
if (!slot.hasMore) return slot;
|
||||
|
||||
const fetchTicket = ++slot._fetchSeq;
|
||||
const params = new URLSearchParams();
|
||||
const limit = opts.limit ?? 20;
|
||||
params.append('limit', String(limit));
|
||||
@@ -529,6 +552,13 @@ export function useSessionStore() {
|
||||
const data = body?.data ?? body;
|
||||
const olderMessages: NormalizedMessage[] = data.messages || [];
|
||||
|
||||
// A full fetch/refresh replaced serverMessages while this page was in
|
||||
// flight — prepending onto the new array would duplicate or misorder.
|
||||
if (fetchTicket <= slot._appliedFetchSeq) {
|
||||
return slot;
|
||||
}
|
||||
slot._appliedFetchSeq = fetchTicket;
|
||||
|
||||
// Prepend older messages (they're earlier in the conversation)
|
||||
slot.serverMessages = [...olderMessages, ...slot.serverMessages];
|
||||
slot.hasMore = Boolean(data.hasMore);
|
||||
@@ -588,6 +618,7 @@ export function useSessionStore() {
|
||||
sessionId: string,
|
||||
) => {
|
||||
const slot = getSlot(sessionId);
|
||||
const fetchTicket = ++slot._fetchSeq;
|
||||
try {
|
||||
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages`;
|
||||
const response = await authenticatedFetch(url);
|
||||
@@ -596,6 +627,14 @@ export function useSessionStore() {
|
||||
const body = await response.json();
|
||||
const data = body?.data ?? body;
|
||||
|
||||
// A later-started fetch already applied: applying this stale transcript
|
||||
// would erase rows the user has already seen (and re-prune realtime
|
||||
// rows against an outdated snapshot).
|
||||
if (fetchTicket <= slot._appliedFetchSeq) {
|
||||
return;
|
||||
}
|
||||
slot._appliedFetchSeq = fetchTicket;
|
||||
|
||||
slot.serverMessages = data.messages || [];
|
||||
slot.total = data.total ?? slot.serverMessages.length;
|
||||
slot.hasMore = Boolean(data.hasMore);
|
||||
|
||||
Reference in New Issue
Block a user