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);
|
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.
|
* Test-only escape hatch: clears every tracked run.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -212,8 +212,10 @@ async function handleChatSend(
|
|||||||
} finally {
|
} finally {
|
||||||
// Safety net: a runtime that crashed (or resolved) without emitting its
|
// Safety net: a runtime that crashed (or resolved) without emitting its
|
||||||
// terminal `complete` would otherwise leave the session stuck in
|
// terminal `complete` would otherwise leave the session stuck in
|
||||||
// "processing" forever on every connected client.
|
// "processing" forever on every connected client. Scoped to THIS run —
|
||||||
chatRunRegistry.completeRun(sessionId, { exitCode: 1 });
|
// 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 () => {
|
test('listRunningRuns returns only currently running app sessions', async () => {
|
||||||
await withIsolatedDatabase(() => {
|
await withIsolatedDatabase(() => {
|
||||||
sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo');
|
sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/Palett
|
|||||||
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
||||||
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
||||||
import { useProjectsState } from '../../hooks/useProjectsState';
|
import { useProjectsState } from '../../hooks/useProjectsState';
|
||||||
|
import { useQueuedMessageAutoSend } from '../../hooks/useQueuedMessageAutoSend';
|
||||||
import { api } from '../../utils/api';
|
import { api } from '../../utils/api';
|
||||||
|
|
||||||
type RunningSessionApiItem = {
|
type RunningSessionApiItem = {
|
||||||
@@ -84,6 +85,17 @@ function AppContentInner() {
|
|||||||
activeSessions: processingSessions,
|
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 () => {
|
const refreshRunningSessions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.runningSessions();
|
const response = await api.runningSessions();
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import { useDropzone } from 'react-dropzone';
|
|||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
||||||
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
||||||
import { safeLocalStorage } from '../utils/chatStorage';
|
import {
|
||||||
|
clearQueuedMessage,
|
||||||
|
readQueuedMessage,
|
||||||
|
safeLocalStorage,
|
||||||
|
writeQueuedMessage,
|
||||||
|
type QueuedSendOptions,
|
||||||
|
} from '../utils/chatStorage';
|
||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
PendingPermissionRequest,
|
PendingPermissionRequest,
|
||||||
@@ -147,6 +153,18 @@ const createFakeSubmitEvent = () => {
|
|||||||
export type QueuedDraft = {
|
export type QueuedDraft = {
|
||||||
content: string;
|
content: string;
|
||||||
images: File[];
|
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 = (
|
const getNotificationSessionSummary = (
|
||||||
@@ -227,9 +245,13 @@ export function useChatComposerState({
|
|||||||
if (typeof window === 'undefined' || !sessionKey) {
|
if (typeof window === 'undefined' || !sessionKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const saved = safeLocalStorage.getItem(`queued_message_${sessionKey}`);
|
return restoreQueuedDraft(sessionKey);
|
||||||
return saved ? { content: saved, images: [] } : null;
|
|
||||||
});
|
});
|
||||||
|
// 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(
|
const handleBuiltInCommand = useCallback(
|
||||||
(result: CommandExecutionResult) => {
|
(result: CommandExecutionResult) => {
|
||||||
@@ -561,6 +583,66 @@ export function useChatComposerState({
|
|||||||
noKeyboard: true,
|
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(
|
const handleSubmit = useCallback(
|
||||||
async (
|
async (
|
||||||
event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>,
|
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,
|
// It's auto-flushed (re-running this same function) once the turn ends,
|
||||||
// so it still goes through slash-command interception, image upload, etc.
|
// so it still goes through slash-command interception, image upload, etc.
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
setQueuedDraft({ content: currentInput, images: attachedImages });
|
queuedDraftSessionRef.current = sessionKey;
|
||||||
|
setQueuedDraft({
|
||||||
|
content: currentInput,
|
||||||
|
images: attachedImages,
|
||||||
|
options: buildSendOptions(currentInput),
|
||||||
|
});
|
||||||
setInput('');
|
setInput('');
|
||||||
inputValueRef.current = '';
|
inputValueRef.current = '';
|
||||||
setAttachedImages([]);
|
setAttachedImages([]);
|
||||||
@@ -728,42 +815,6 @@ export function useChatComposerState({
|
|||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
setTimeout(() => scrollToBottom(), 100);
|
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
|
// One message shape for every provider. The backend resolves the
|
||||||
// provider, project path, and provider-native resume id from the
|
// provider, project path, and provider-native resume id from the
|
||||||
// session row; `options` only carries composer-level preferences.
|
// session row; `options` only carries composer-level preferences.
|
||||||
@@ -772,12 +823,7 @@ export function useChatComposerState({
|
|||||||
sessionId: targetSessionId,
|
sessionId: targetSessionId,
|
||||||
content: messageContent,
|
content: messageContent,
|
||||||
options: {
|
options: {
|
||||||
model,
|
...buildSendOptions(messageContent),
|
||||||
effort,
|
|
||||||
permissionMode: resolvePermissionModeForProvider(provider, permissionMode),
|
|
||||||
toolsSettings,
|
|
||||||
skipPermissions: toolsSettings?.skipPermissions || false,
|
|
||||||
sessionSummary,
|
|
||||||
images: uploadedImages,
|
images: uploadedImages,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -799,23 +845,18 @@ export function useChatComposerState({
|
|||||||
[
|
[
|
||||||
selectedSession,
|
selectedSession,
|
||||||
attachedImages,
|
attachedImages,
|
||||||
claudeModel,
|
buildSendOptions,
|
||||||
codexModel,
|
|
||||||
currentProviderEffort,
|
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
cursorModel,
|
|
||||||
executeCommand,
|
executeCommand,
|
||||||
opencodeModel,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionEstablished,
|
onSessionEstablished,
|
||||||
permissionMode,
|
|
||||||
provider,
|
provider,
|
||||||
resolvePermissionModeForProvider,
|
|
||||||
resetCommandMenuState,
|
resetCommandMenuState,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
sessionKey,
|
||||||
addMessage,
|
addMessage,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
@@ -829,21 +870,47 @@ export function useChatComposerState({
|
|||||||
// Once the in-flight turn ends, replay the queued draft through the normal
|
// Once the in-flight turn ends, replay the queued draft through the normal
|
||||||
// submit path (slash commands, image upload, etc. all still apply).
|
// submit path (slash commands, image upload, etc. all still apply).
|
||||||
const wasLoadingRef = useRef(isLoading);
|
const wasLoadingRef = useRef(isLoading);
|
||||||
|
const flushSessionKeyRef = useRef(sessionKey);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wasLoading = wasLoadingRef.current;
|
const wasLoading = wasLoadingRef.current;
|
||||||
wasLoadingRef.current = isLoading;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setQueuedDraft(null);
|
if (isLoading || !queuedDraft) {
|
||||||
setInput(queuedDraft.content);
|
return;
|
||||||
inputValueRef.current = queuedDraft.content;
|
}
|
||||||
setAttachedImages(queuedDraft.images);
|
|
||||||
setTimeout(() => {
|
// Turn just ended in this session: flush immediately. Otherwise this is a
|
||||||
handleSubmitRef.current?.(createFakeSubmitEvent());
|
// saved draft restored into an apparently idle session — hold it briefly
|
||||||
}, 0);
|
// so the `chat_subscribed` ack can flip `isLoading` if a run is actually
|
||||||
}, [isLoading, queuedDraft]);
|
// 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(() => {
|
const editQueuedDraft = useCallback(() => {
|
||||||
if (!queuedDraft) {
|
if (!queuedDraft) {
|
||||||
@@ -898,28 +965,33 @@ export function useChatComposerState({
|
|||||||
}
|
}
|
||||||
}, [input, selectedProjectId]);
|
}, [input, selectedProjectId]);
|
||||||
|
|
||||||
// Switching sessions swaps in that session's queued draft (image
|
// Persist the queued draft under its session's key. Must be defined BEFORE
|
||||||
// attachments can't survive a reload, so only the text is restored).
|
// 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(() => {
|
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) {
|
if (!sessionKey) {
|
||||||
setQueuedDraft(null);
|
setQueuedDraft(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const saved = safeLocalStorage.getItem(`queued_message_${sessionKey}`);
|
setQueuedDraft(restoreQueuedDraft(sessionKey));
|
||||||
setQueuedDraft(saved ? { content: saved, images: [] } : null);
|
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!textareaRef.current) {
|
if (!textareaRef.current) {
|
||||||
return;
|
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 {
|
export function getClaudeSettings(): ClaudeSettings {
|
||||||
const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY);
|
const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY);
|
||||||
if (!raw) {
|
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 */
|
/** @internal Cache-invalidation refs for computeMerged */
|
||||||
_lastServerRef: NormalizedMessage[];
|
_lastServerRef: NormalizedMessage[];
|
||||||
_lastRealtimeRef: 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;
|
status: SessionStatus;
|
||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -120,6 +130,8 @@ function createEmptySlot(): SessionSlot {
|
|||||||
hasMore: false,
|
hasMore: false,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
tokenUsage: null,
|
tokenUsage: null,
|
||||||
|
_fetchSeq: 0,
|
||||||
|
_appliedFetchSeq: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,6 +471,7 @@ export function useSessionStore() {
|
|||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
const slot = getSlot(sessionId);
|
const slot = getSlot(sessionId);
|
||||||
|
const fetchTicket = ++slot._fetchSeq;
|
||||||
slot.status = 'loading';
|
slot.status = 'loading';
|
||||||
notify(sessionId);
|
notify(sessionId);
|
||||||
|
|
||||||
@@ -481,6 +494,12 @@ export function useSessionStore() {
|
|||||||
const data = body?.data ?? body;
|
const data = body?.data ?? body;
|
||||||
const messages: NormalizedMessage[] = data.messages || [];
|
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.serverMessages = messages;
|
||||||
slot.total = data.total ?? messages.length;
|
slot.total = data.total ?? messages.length;
|
||||||
slot.hasMore = Boolean(data.hasMore);
|
slot.hasMore = Boolean(data.hasMore);
|
||||||
@@ -496,8 +515,11 @@ export function useSessionStore() {
|
|||||||
return slot;
|
return slot;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[SessionStore] fetch failed for ${sessionId}:`, error);
|
console.error(`[SessionStore] fetch failed for ${sessionId}:`, error);
|
||||||
slot.status = 'error';
|
// Don't clobber a newer fetch's result with a stale failure.
|
||||||
notify(sessionId);
|
if (fetchTicket > slot._appliedFetchSeq) {
|
||||||
|
slot.status = 'error';
|
||||||
|
notify(sessionId);
|
||||||
|
}
|
||||||
return slot;
|
return slot;
|
||||||
}
|
}
|
||||||
}, [getSlot, notify]);
|
}, [getSlot, notify]);
|
||||||
@@ -514,6 +536,7 @@ export function useSessionStore() {
|
|||||||
const slot = getSlot(sessionId);
|
const slot = getSlot(sessionId);
|
||||||
if (!slot.hasMore) return slot;
|
if (!slot.hasMore) return slot;
|
||||||
|
|
||||||
|
const fetchTicket = ++slot._fetchSeq;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
const limit = opts.limit ?? 20;
|
const limit = opts.limit ?? 20;
|
||||||
params.append('limit', String(limit));
|
params.append('limit', String(limit));
|
||||||
@@ -529,6 +552,13 @@ export function useSessionStore() {
|
|||||||
const data = body?.data ?? body;
|
const data = body?.data ?? body;
|
||||||
const olderMessages: NormalizedMessage[] = data.messages || [];
|
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)
|
// Prepend older messages (they're earlier in the conversation)
|
||||||
slot.serverMessages = [...olderMessages, ...slot.serverMessages];
|
slot.serverMessages = [...olderMessages, ...slot.serverMessages];
|
||||||
slot.hasMore = Boolean(data.hasMore);
|
slot.hasMore = Boolean(data.hasMore);
|
||||||
@@ -588,6 +618,7 @@ export function useSessionStore() {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
) => {
|
) => {
|
||||||
const slot = getSlot(sessionId);
|
const slot = getSlot(sessionId);
|
||||||
|
const fetchTicket = ++slot._fetchSeq;
|
||||||
try {
|
try {
|
||||||
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages`;
|
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages`;
|
||||||
const response = await authenticatedFetch(url);
|
const response = await authenticatedFetch(url);
|
||||||
@@ -596,6 +627,14 @@ export function useSessionStore() {
|
|||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
const data = body?.data ?? body;
|
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.serverMessages = data.messages || [];
|
||||||
slot.total = data.total ?? slot.serverMessages.length;
|
slot.total = data.total ?? slot.serverMessages.length;
|
||||||
slot.hasMore = Boolean(data.hasMore);
|
slot.hasMore = Boolean(data.hasMore);
|
||||||
|
|||||||
Reference in New Issue
Block a user