2025-12-29 02:16:42 +02:00
|
|
|
import { create } from 'zustand';
|
2026-06-30 04:47:52 -04:00
|
|
|
import { devtools, persist } from 'zustand/middleware';
|
|
|
|
|
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
2025-12-29 02:16:42 +02:00
|
|
|
import type { AttachedFile } from './types/sessionTypes';
|
|
|
|
|
import { updateDesktopSettings } from '@/lib/persistence';
|
2026-07-21 20:52:20 +03:00
|
|
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
|
|
|
|
import { normalizePath } from '@/lib/pathNormalization';
|
2025-12-29 02:16:42 +02:00
|
|
|
|
2026-06-29 09:28:20 +11:00
|
|
|
export type FollowUpBehavior = 'steer' | 'queue';
|
|
|
|
|
|
|
|
|
|
export const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = 'queue';
|
|
|
|
|
|
|
|
|
|
export const isFollowUpBehavior = (value: unknown): value is FollowUpBehavior => (
|
|
|
|
|
value === 'steer' || value === 'queue'
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const normalizeFollowUpBehavior = (
|
|
|
|
|
value: unknown,
|
|
|
|
|
legacyQueueModeEnabled?: boolean | null,
|
|
|
|
|
): FollowUpBehavior => {
|
|
|
|
|
// "immediate" was removed: on a busy session it was wire-identical to
|
|
|
|
|
// "steer" (OpenCode only supports delivery "steer" | "queue", defaulting
|
|
|
|
|
// to "steer"), so collapse any persisted/legacy "immediate" onto "steer".
|
|
|
|
|
if (value === 'immediate') {
|
|
|
|
|
return 'steer';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isFollowUpBehavior(value)) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (legacyQueueModeEnabled === false) {
|
|
|
|
|
return 'steer';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (legacyQueueModeEnabled === true) {
|
|
|
|
|
return 'queue';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return DEFAULT_FOLLOW_UP_BEHAVIOR;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-29 02:16:42 +02:00
|
|
|
export interface QueuedMessage {
|
|
|
|
|
id: string;
|
|
|
|
|
content: string;
|
|
|
|
|
attachments?: AttachedFile[];
|
|
|
|
|
createdAt: number;
|
2026-03-31 18:47:00 +03:00
|
|
|
/** Send config captured at queue time — used as-is when auto-sending */
|
|
|
|
|
sendConfig?: {
|
|
|
|
|
providerID: string;
|
|
|
|
|
modelID: string;
|
|
|
|
|
agent?: string;
|
|
|
|
|
variant?: string;
|
|
|
|
|
};
|
2025-12-29 02:16:42 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
export type MessageQueueTarget = {
|
|
|
|
|
runtimeKey: string;
|
|
|
|
|
directory: string;
|
|
|
|
|
sessionId: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const MAX_QUEUE_TARGETS = 50;
|
|
|
|
|
const MAX_MESSAGES_PER_QUEUE = 20;
|
|
|
|
|
|
|
|
|
|
export const createMessageQueueTarget = (
|
|
|
|
|
sessionId: string,
|
|
|
|
|
directory: string | null | undefined,
|
|
|
|
|
runtimeKey: string = getRuntimeKey(),
|
|
|
|
|
): MessageQueueTarget | null => {
|
|
|
|
|
const normalizedDirectory = normalizePath(directory);
|
|
|
|
|
if (!runtimeKey || !normalizedDirectory || !sessionId) return null;
|
|
|
|
|
return { runtimeKey, directory: normalizedDirectory, sessionId };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const getMessageQueueKey = (target: MessageQueueTarget): string =>
|
|
|
|
|
`${target.runtimeKey}\n${target.directory}\n${target.sessionId}`;
|
|
|
|
|
|
|
|
|
|
export const parseMessageQueueKey = (key: string): MessageQueueTarget | null => {
|
|
|
|
|
const [runtimeKey, directory, ...sessionParts] = key.split('\n');
|
|
|
|
|
return createMessageQueueTarget(sessionParts.join('\n'), directory, runtimeKey);
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-29 02:16:42 +02:00
|
|
|
interface MessageQueueState {
|
2026-07-21 20:52:20 +03:00
|
|
|
queuedMessages: Record<string, QueuedMessage[]>; // runtime + directory + session → queue
|
|
|
|
|
quarantinedLegacyMessages: Record<string, QueuedMessage[]>;
|
2026-06-29 09:28:20 +11:00
|
|
|
followUpBehavior: FollowUpBehavior;
|
2025-12-29 02:16:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MessageQueueActions {
|
2026-07-21 20:52:20 +03:00
|
|
|
addToQueue: (target: MessageQueueTarget, message: Omit<QueuedMessage, 'id' | 'createdAt'>) => void;
|
|
|
|
|
removeFromQueue: (target: MessageQueueTarget, messageId: string) => void;
|
|
|
|
|
reorderQueue: (target: MessageQueueTarget, fromId: string, toId: string) => void;
|
|
|
|
|
popToInput: (target: MessageQueueTarget, messageId: string) => QueuedMessage | null;
|
|
|
|
|
clearQueue: (target: MessageQueueTarget) => void;
|
2025-12-29 02:16:42 +02:00
|
|
|
clearAllQueues: () => void;
|
2026-06-29 09:28:20 +11:00
|
|
|
setFollowUpBehavior: (behavior: FollowUpBehavior) => void;
|
2026-07-21 20:52:20 +03:00
|
|
|
getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[];
|
2025-12-29 02:16:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type MessageQueueStore = MessageQueueState & MessageQueueActions;
|
|
|
|
|
|
2026-06-29 09:28:20 +11:00
|
|
|
type PersistedMessageQueueState = {
|
|
|
|
|
queuedMessages?: Record<string, QueuedMessage[]>;
|
2026-07-21 20:52:20 +03:00
|
|
|
quarantinedLegacyMessages?: Record<string, QueuedMessage[]>;
|
2026-06-29 09:28:20 +11:00
|
|
|
followUpBehavior?: FollowUpBehavior;
|
|
|
|
|
queueModeEnabled?: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
export const migrateMessageQueueState = (persistedState: unknown, version: number): Partial<MessageQueueStore> => {
|
|
|
|
|
const state = (persistedState ?? {}) as PersistedMessageQueueState;
|
|
|
|
|
const legacyQueues = version < 2 ? (state.queuedMessages ?? {}) : {};
|
|
|
|
|
return {
|
|
|
|
|
queuedMessages: version < 2 ? {} : (state.queuedMessages ?? {}),
|
|
|
|
|
quarantinedLegacyMessages: {
|
|
|
|
|
...(state.quarantinedLegacyMessages ?? {}),
|
|
|
|
|
...legacyQueues,
|
|
|
|
|
},
|
|
|
|
|
followUpBehavior: normalizeFollowUpBehavior(state.followUpBehavior, state.queueModeEnabled ?? null),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-29 02:16:42 +02:00
|
|
|
export const useMessageQueueStore = create<MessageQueueStore>()(
|
|
|
|
|
devtools(
|
|
|
|
|
persist(
|
|
|
|
|
(set, get) => ({
|
|
|
|
|
queuedMessages: {},
|
2026-07-21 20:52:20 +03:00
|
|
|
quarantinedLegacyMessages: {},
|
2026-06-29 09:28:20 +11:00
|
|
|
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
|
2025-12-29 02:16:42 +02:00
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
addToQueue: (target, message) => {
|
|
|
|
|
const key = getMessageQueueKey(target);
|
2025-12-29 02:16:42 +02:00
|
|
|
const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
|
|
|
const queuedMessage: QueuedMessage = {
|
|
|
|
|
id,
|
|
|
|
|
content: message.content,
|
|
|
|
|
attachments: message.attachments,
|
|
|
|
|
createdAt: Date.now(),
|
2026-03-31 18:47:00 +03:00
|
|
|
sendConfig: message.sendConfig,
|
2025-12-29 02:16:42 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
set((state) => {
|
2026-07-21 20:52:20 +03:00
|
|
|
const currentQueue = state.queuedMessages[key] ?? [];
|
|
|
|
|
const queuedMessages = {
|
|
|
|
|
...state.queuedMessages,
|
|
|
|
|
[key]: [...currentQueue, queuedMessage].slice(-MAX_MESSAGES_PER_QUEUE),
|
|
|
|
|
};
|
|
|
|
|
const keys = Object.keys(queuedMessages);
|
|
|
|
|
if (keys.length > MAX_QUEUE_TARGETS) {
|
|
|
|
|
keys.sort((left, right) => (
|
|
|
|
|
(queuedMessages[left]?.[0]?.createdAt ?? 0) - (queuedMessages[right]?.[0]?.createdAt ?? 0)
|
|
|
|
|
));
|
|
|
|
|
for (const staleKey of keys.slice(0, keys.length - MAX_QUEUE_TARGETS)) delete queuedMessages[staleKey];
|
|
|
|
|
}
|
2025-12-29 02:16:42 +02:00
|
|
|
return {
|
2026-07-21 20:52:20 +03:00
|
|
|
queuedMessages,
|
2025-12-29 02:16:42 +02:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
removeFromQueue: (target, messageId) => {
|
|
|
|
|
const key = getMessageQueueKey(target);
|
2025-12-29 02:16:42 +02:00
|
|
|
set((state) => {
|
2026-07-21 20:52:20 +03:00
|
|
|
const currentQueue = state.queuedMessages[key] ?? [];
|
2025-12-29 02:16:42 +02:00
|
|
|
const newQueue = currentQueue.filter((m) => m.id !== messageId);
|
|
|
|
|
|
|
|
|
|
if (newQueue.length === 0) {
|
2026-07-21 20:52:20 +03:00
|
|
|
const { [key]: _removed, ...rest } = state.queuedMessages;
|
2025-12-29 02:16:42 +02:00
|
|
|
void _removed;
|
|
|
|
|
return { queuedMessages: rest };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
queuedMessages: {
|
|
|
|
|
...state.queuedMessages,
|
2026-07-21 20:52:20 +03:00
|
|
|
[key]: newQueue,
|
2025-12-29 02:16:42 +02:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
reorderQueue: (target, fromId, toId) => {
|
2026-06-26 19:35:45 +03:00
|
|
|
if (fromId === toId) return;
|
2026-07-21 20:52:20 +03:00
|
|
|
const key = getMessageQueueKey(target);
|
2026-06-26 19:35:45 +03:00
|
|
|
set((state) => {
|
2026-07-21 20:52:20 +03:00
|
|
|
const currentQueue = state.queuedMessages[key];
|
2026-06-26 19:35:45 +03:00
|
|
|
if (!currentQueue) return state;
|
|
|
|
|
const fromIndex = currentQueue.findIndex((m) => m.id === fromId);
|
|
|
|
|
const toIndex = currentQueue.findIndex((m) => m.id === toId);
|
|
|
|
|
if (fromIndex === -1 || toIndex === -1) return state;
|
|
|
|
|
|
|
|
|
|
const newQueue = currentQueue.slice();
|
|
|
|
|
const [moved] = newQueue.splice(fromIndex, 1);
|
|
|
|
|
newQueue.splice(toIndex, 0, moved);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
queuedMessages: {
|
|
|
|
|
...state.queuedMessages,
|
2026-07-21 20:52:20 +03:00
|
|
|
[key]: newQueue,
|
2026-06-26 19:35:45 +03:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
popToInput: (target, messageId) => {
|
|
|
|
|
const key = getMessageQueueKey(target);
|
2025-12-29 02:16:42 +02:00
|
|
|
const state = get();
|
2026-07-21 20:52:20 +03:00
|
|
|
const currentQueue = state.queuedMessages[key] ?? [];
|
2025-12-29 02:16:42 +02:00
|
|
|
const message = currentQueue.find((m) => m.id === messageId);
|
|
|
|
|
|
|
|
|
|
if (!message) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove from queue
|
|
|
|
|
set((prevState) => {
|
2026-07-21 20:52:20 +03:00
|
|
|
const queue = prevState.queuedMessages[key] ?? [];
|
2025-12-29 02:16:42 +02:00
|
|
|
const newQueue = queue.filter((m) => m.id !== messageId);
|
|
|
|
|
|
|
|
|
|
if (newQueue.length === 0) {
|
2026-07-21 20:52:20 +03:00
|
|
|
const { [key]: _removed, ...rest } = prevState.queuedMessages;
|
2025-12-29 02:16:42 +02:00
|
|
|
void _removed;
|
|
|
|
|
return { queuedMessages: rest };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
queuedMessages: {
|
|
|
|
|
...prevState.queuedMessages,
|
2026-07-21 20:52:20 +03:00
|
|
|
[key]: newQueue,
|
2025-12-29 02:16:42 +02:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return message;
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
clearQueue: (target) => {
|
|
|
|
|
const key = getMessageQueueKey(target);
|
2025-12-29 02:16:42 +02:00
|
|
|
set((state) => {
|
2026-07-21 20:52:20 +03:00
|
|
|
const { [key]: _removed, ...rest } = state.queuedMessages;
|
2025-12-29 02:16:42 +02:00
|
|
|
void _removed;
|
|
|
|
|
return { queuedMessages: rest };
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
clearAllQueues: () => {
|
|
|
|
|
set({ queuedMessages: {} });
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 09:28:20 +11:00
|
|
|
setFollowUpBehavior: (behavior) => {
|
|
|
|
|
set({ followUpBehavior: behavior });
|
|
|
|
|
void updateDesktopSettings({ followUpBehavior: behavior });
|
2025-12-29 02:16:42 +02:00
|
|
|
},
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
getQueueForTarget: (target) => {
|
|
|
|
|
return get().queuedMessages[getMessageQueueKey(target)] ?? [];
|
2025-12-29 02:16:42 +02:00
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
name: 'message-queue-store',
|
2026-07-21 20:52:20 +03:00
|
|
|
version: 2,
|
2026-06-30 04:47:52 -04:00
|
|
|
storage: createDeferredSafeJSONStorage(),
|
2025-12-29 02:16:42 +02:00
|
|
|
partialize: (state) => ({
|
|
|
|
|
queuedMessages: state.queuedMessages,
|
2026-07-21 20:52:20 +03:00
|
|
|
quarantinedLegacyMessages: state.quarantinedLegacyMessages,
|
2026-06-29 09:28:20 +11:00
|
|
|
followUpBehavior: state.followUpBehavior,
|
2025-12-29 02:16:42 +02:00
|
|
|
}),
|
2026-07-21 20:52:20 +03:00
|
|
|
migrate: migrateMessageQueueState,
|
2025-12-29 02:16:42 +02:00
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
{
|
|
|
|
|
name: 'message-queue-store',
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|