diff --git a/CHANGELOG.md b/CHANGELOG.md index e69772f..300ab9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ ### Добавлено +- добавление сообщения по Enter с многострочным вводом через Ctrl+Enter и + Shift+Enter; +- подтверждение очистки непустой очереди и сохранение свёрнутости панели; +- отдельные очереди и межвкладочные lease для каждого chat id; +- перенос временной очереди из `/` в созданный `/c/` без потери элементов; +- inline-редактирование элементов очереди с Save и Cancel; +- drag-and-drop через отдельный handle с подсветкой места вставки; +- визуальные состояния элементов и ручной Retry для ошибок; - межвкладочное лидерство через `localStorage` lease и `BroadcastChannel`; - TTL, периодическое продление и автоматический захват lock после зависания или закрытия вкладки; @@ -18,6 +26,10 @@ ### Изменено +- ожидающие элементы отображаются полупрозрачно, а активные и ошибочные + элементы визуально выделяются; +- удаление после фактического начала отправки больше не скрывает активный + элемент и не имитирует отмену; - перед следующей отправкой ChatGPT должен оставаться стабильно свободным; - незавершённые DOM-наблюдатели отменяются при смене чата или потере лидерства; diff --git a/README.md b/README.md index 21b744b..b5923f8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ ## Возможности - добавление нескольких запросов в очередь; -- изменение порядка и удаление запросов; +- inline-редактирование запросов до отправки; +- drag-and-drop, кнопки перестановки и удаление отдельных запросов; +- визуальные статусы и явный повтор элементов с ошибкой; - приостановка и продолжение обработки; - повторная попытка отправки при временной ошибке; - безопасное ожидание после Stop и повторной генерации ответа; @@ -56,6 +58,10 @@ npm run build 4. Скрипт дождётся доступности основного поля ввода и отправит запрос. 5. Следующий запрос будет отправлен после завершения текущего ответа. +В поле очереди обычный `Enter` добавляет сообщение. `Ctrl+Enter` и +`Shift+Enter` оставляют перенос строки для многострочного текста. Панель +запоминает свёрнутое состояние между обновлениями страницы. + Кнопки панели позволяют приостановить очередь, продолжить обработку, изменить порядок запросов и удалить отдельные элементы. @@ -80,6 +86,16 @@ npm run dev npm run validate ``` +Для браузерной regression-проверки очередей разных чатов запустите из корня +проекта `python -m http.server 8765` и откройте +`http://127.0.0.1:8765/test/e2e/per-chat-queues.html`. Страница автоматически +проверяет общую очередь двух вкладок одного чата, изоляцию разных chat id и +миграцию временной очереди после создания чата. + +`http://127.0.0.1:8765/test/e2e/panel-actions.html` проверяет клавиатурное +добавление, многострочный ввод, подтверждение очистки, доступные имена кнопок и +восстановление свёрнутости панели. + Доступные команды: | Команда | Назначение | @@ -120,13 +136,14 @@ userscript хранится в Git, чтобы его можно было уст Проект разделён на небольшие модули с явными зонами ответственности: - `main.js` — composition root, создающий и связывающий компоненты; -- `queue-store.js` — хранение, нормализация и изменение элементов очереди; +- `chat-scope.js` — единое преобразование маршрута ChatGPT в chat scope; +- `queue-store.js` — хранение, миграция и изменение очереди текущего чата; - `queue-processor.js` — конечный автомат обработки, ожидание и повторы; - `chatgpt-adapter.js` — единственная граница взаимодействия с DOM ChatGPT; - `panel-ui.js` — создание панели и обработчики действий пользователя; - `styles.js` — стили панели; - `utils.js` — общие утилиты без состояния; -- `tab-lock.js` — межвкладочный lease с TTL, продлением лидерства и +- `tab-lock.js` — scoped по chat id межвкладочный lease с TTL и автоматическим освобождением после закрытия или зависания вкладки. ```mermaid diff --git a/dist/chatgpt-queue.user.js b/dist/chatgpt-queue.user.js index 4e5679d..0a0d6b6 100644 --- a/dist/chatgpt-queue.user.js +++ b/dist/chatgpt-queue.user.js @@ -528,21 +528,59 @@ }; } + // src/chat-scope.js + var TEMPORARY_QUEUE_SCOPE = "temporary"; + function getQueueScope(chatIdentity) { + const path = String(chatIdentity || "/").split(/[?#]/, 1)[0].replace(/\/+$/, "") || "/"; + const chatMatch = path.match(/^\/c\/([^/]+)$/); + if (chatMatch) { + try { + return `chat:${decodeURIComponent(chatMatch[1])}`; + } catch { + return `chat:${chatMatch[1]}`; + } + } + if (path === "/") { + return TEMPORARY_QUEUE_SCOPE; + } + return `route:${path}`; + } + // src/panel-ui.js var POSITION_KEY = "chatgpt_prompt_queue_position_v1"; + var COLLAPSED_KEY = "chatgpt_prompt_queue_collapsed_v1"; + var EDITING_STATUSES = /* @__PURE__ */ new Set(["editing", "editing_error"]); + var STATUS_VIEW = Object.freeze({ + pending: { className: "pending", label: "Ожидает" }, + editing: { className: "editing", label: "Редактируется" }, + editing_error: { className: "editing", label: "Редактируется" }, + preparing: { className: "active", label: "Подготовка" }, + sending: { className: "active", label: "Отправляется" }, + error: { className: "error", label: "Ошибка" } + }); function createPanelUi({ store, processor, - storage = localStorage + storage = localStorage, + confirmAction = (message) => window.confirm(message) }) { let panel = null; let mounted = false; + let editingItemId = null; + let editDraft = ""; + let draggedItemId = null; + let dropTargetId = null; + let dropPlacement = null; function createButton(text, handler) { const button = document.createElement("button"); button.textContent = text; button.addEventListener("click", handler); return button; } + function setButtonLabel(button, label) { + button.title = label; + button.setAttribute("aria-label", label); + } function updateStatus(text) { const status = document.querySelector("#queue-status"); if (status) { @@ -555,40 +593,252 @@ return; } button.textContent = paused ? "▶" : "⏸"; - button.title = paused ? "Продолжить очередь" : "Приостановить очередь"; + const label = paused ? "Продолжить очередь" : "Приостановить очередь"; + setButtonLabel(button, label); button.classList.toggle("queue-paused", paused); } + function getStatusView(status) { + return STATUS_VIEW[status] || { + className: "unknown", + label: status || "Неизвестно" + }; + } + function findRenderedItem(id) { + return Array.from( + document.querySelectorAll("#queue-list .queue-item") + ).find((item) => item.dataset.queueId === id) || null; + } + function clearDropIndicator() { + document.querySelectorAll( + ".queue-drop-before, .queue-drop-after" + ).forEach((item) => { + item.classList.remove( + "queue-drop-before", + "queue-drop-after" + ); + }); + dropTargetId = null; + dropPlacement = null; + } + function resetDragState() { + clearDropIndicator(); + if (draggedItemId) { + findRenderedItem(draggedItemId)?.classList.remove("queue-item-dragging"); + } + draggedItemId = null; + document.body.classList.remove("queue-item-drag-active"); + } + function startEditing(prompt) { + editingItemId = prompt.id; + editDraft = prompt.text; + if (!processor.beginPromptEdit(prompt.id)) { + editingItemId = null; + editDraft = ""; + renderQueue(); + return; + } + findRenderedItem(prompt.id)?.querySelector(".queue-item-editor")?.focus(); + } + function saveEditing() { + const id = editingItemId; + const text = editDraft.trim(); + if (!id || !text) { + findRenderedItem(id)?.querySelector(".queue-item-editor")?.setAttribute("aria-invalid", "true"); + return; + } + editingItemId = null; + if (!processor.savePromptEdit(id, text)) { + editingItemId = id; + renderQueue(); + return; + } + editDraft = ""; + } + function cancelEditing() { + const id = editingItemId; + if (!id) { + return; + } + editingItemId = null; + editDraft = ""; + if (!processor.cancelPromptEdit(id)) { + renderQueue(); + } + } + function createDragHandle(prompt, movable) { + const handle = createButton("⋮⋮", () => { + }); + handle.className = "queue-item-drag-handle"; + handle.title = movable ? "Перетащить сообщение" : "Это сообщение сейчас нельзя перемещать"; + setButtonLabel(handle, handle.title); + handle.disabled = !movable; + handle.addEventListener("mousedown", (event) => { + if (!movable || event.button !== 0) { + return; + } + clearDropIndicator(); + draggedItemId = prompt.id; + findRenderedItem(prompt.id)?.classList.add("queue-item-dragging"); + document.body.classList.add("queue-item-drag-active"); + event.preventDefault(); + event.stopPropagation(); + }); + return handle; + } + function enableQueueItemDragging() { + document.addEventListener("mousemove", (event) => { + if (!draggedItemId) { + return; + } + const item = document.elementFromPoint(event.clientX, event.clientY)?.closest(".queue-item"); + const targetId = item?.dataset.queueId; + if (!item || !targetId || targetId === draggedItemId) { + clearDropIndicator(); + return; + } + const rect = item.getBoundingClientRect(); + const placement = event.clientY < rect.top + rect.height / 2 ? "before" : "after"; + if (dropTargetId === targetId && dropPlacement === placement) { + return; + } + clearDropIndicator(); + dropTargetId = targetId; + dropPlacement = placement; + item.classList.add(`queue-drop-${placement}`); + }); + document.addEventListener("mouseup", () => { + if (!draggedItemId) { + return; + } + const sourceId = draggedItemId; + const targetId = dropTargetId; + const placement = dropPlacement; + resetDragState(); + if (targetId && placement) { + processor.movePromptRelative( + sourceId, + targetId, + placement + ); + } + }); + window.addEventListener("blur", resetDragState); + } function renderQueue() { const list = document.querySelector("#queue-list"); if (!list) { return; } + const prompts = store.getItems(); + const editedItem = editingItemId ? store.findById(editingItemId) : null; + if (editingItemId && (!editedItem || !EDITING_STATUSES.has(editedItem.status))) { + editingItemId = null; + editDraft = ""; + } list.innerHTML = ""; - store.getItems().forEach((prompt, index) => { + prompts.forEach((prompt, index) => { + const statusView = getStatusView(prompt.status); + const editing = editingItemId === prompt.id; + const movable = !editing && ![ + "preparing", + "sending" + ].includes(prompt.status); const item = document.createElement("div"); - item.className = "queue-item"; - const text = document.createElement("div"); - text.className = "queue-item-text"; - text.textContent = `${index + 1}. ${prompt.text}`; + item.className = [ + "queue-item", + `queue-item-${statusView.className}` + ].join(" "); + item.dataset.queueId = prompt.id; + const handle = createDragHandle(prompt, movable); + const content = document.createElement("div"); + content.className = "queue-item-content"; + const meta = document.createElement("div"); + meta.className = "queue-item-meta"; + const number = document.createElement("span"); + number.className = "queue-item-number"; + number.textContent = `#${index + 1}`; + const status = document.createElement("span"); + status.className = "queue-item-status"; + status.textContent = statusView.label; + if (prompt.status === "error" && prompt.attempts > 0) { + status.textContent += ` · ${prompt.attempts}`; + } + meta.append(number, status); + content.appendChild(meta); + if (editing) { + const editor = document.createElement("textarea"); + editor.className = "queue-item-editor"; + editor.value = editDraft; + editor.rows = 3; + editor.addEventListener("input", () => { + editDraft = editor.value; + editor.removeAttribute("aria-invalid"); + const save = item.querySelector( + ".queue-item-save" + ); + if (save) { + save.disabled = !editDraft.trim(); + } + }); + content.appendChild(editor); + } else { + const text = document.createElement("div"); + text.className = "queue-item-text"; + text.textContent = prompt.text; + content.appendChild(text); + } const controls = document.createElement("div"); controls.className = "queue-item-controls"; - const up = createButton( - "↑", - () => processor.movePrompt(prompt.id, -1) - ); - const down = createButton( - "↓", - () => processor.movePrompt(prompt.id, 1) - ); - const remove = createButton( - "×", - () => processor.removePrompt(prompt.id) - ); - controls.append(up, down, remove); - item.append(text, controls); + if (editing) { + const save = createButton("Save", saveEditing); + save.className = "queue-item-save"; + save.disabled = !editDraft.trim(); + setButtonLabel(save, "Сохранить изменения"); + const cancel = createButton("Cancel", cancelEditing); + setButtonLabel(cancel, "Отменить редактирование"); + controls.append(save, cancel); + } else { + const up = createButton( + "↑", + () => processor.movePrompt(prompt.id, -1) + ); + setButtonLabel(up, "Переместить сообщение вверх"); + up.disabled = !movable || index === 0; + const down = createButton( + "↓", + () => processor.movePrompt(prompt.id, 1) + ); + setButtonLabel(down, "Переместить сообщение вниз"); + down.disabled = !movable || index === prompts.length - 1; + const edit = createButton( + "Edit", + () => startEditing(prompt) + ); + setButtonLabel(edit, "Редактировать сообщение"); + edit.disabled = !movable; + if (prompt.status === "error") { + const retry = createButton( + "Retry", + () => processor.retryPrompt(prompt.id) + ); + retry.className = "queue-item-retry"; + setButtonLabel(retry, "Повторить отправку сообщения"); + controls.append(retry); + } + const remove = createButton("×", () => { + if (editingItemId === prompt.id) { + editingItemId = null; + editDraft = ""; + } + processor.removePrompt(prompt.id); + }); + setButtonLabel(remove, "Удалить сообщение"); + controls.append(up, down, edit, remove); + } + item.append(handle, content, controls); list.appendChild(item); }); - if (store.getItems().length === 0) { + if (prompts.length === 0) { list.textContent = "Очередь пуста"; } } @@ -687,10 +937,57 @@ const input = document.querySelector("#queue-input"); const text = input.value.trim(); if (!text) { + input.focus(); return; } - input.value = ""; processor.addPrompt(text); + input.value = ""; + input.focus(); + } + function insertInputNewline(input) { + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? start; + input.setRangeText("\n", start, end, "end"); + input.dispatchEvent(new Event("input", { bubbles: true })); + } + function handleInputKeydown(event) { + if (!["Enter", "Process"].includes(event.key)) { + return; + } + if (event.isComposing || event.keyCode === 229 || event.key === "Process" || event.altKey || event.metaKey) { + return; + } + if (event.ctrlKey) { + event.preventDefault(); + insertInputNewline(event.currentTarget); + return; + } + if (event.shiftKey || editingItemId) { + return; + } + event.preventDefault(); + addPrompt(); + } + function requestClearQueue() { + const prompts = store.getItems(); + if (prompts.length === 0) { + return; + } + const sendAlreadyStarted = prompts.some((prompt) => prompt.status === "sending") || [ + "sending", + "awaiting_confirmation" + ].includes(processor.getState?.()); + const message = sendAlreadyStarted ? "Одно сообщение уже передано в ChatGPT и отменить его нельзя. Очистить остальные сообщения очереди?" : `Удалить все сообщения из очереди (${prompts.length})?`; + if (confirmAction(message)) { + processor.clearQueue(); + } + } + function updateCollapsedState(collapsed) { + const body = panel.querySelector("#queue-body"); + const button = panel.querySelector("#queue-collapse"); + body.style.display = collapsed ? "none" : "block"; + button.textContent = collapsed ? "+" : "−"; + setButtonLabel(button, collapsed ? "Развернуть" : "Свернуть"); } function mount() { if (mounted || document.querySelector("#chatgpt-queue-panel")) { @@ -703,8 +1000,16 @@ Очередь ChatGPT
- - + +
@@ -715,8 +1020,16 @@ >
- - + +
Готово
@@ -726,25 +1039,24 @@ document.body.appendChild(panel); restorePanelPosition(panel); enableDragging(panel); + enableQueueItemDragging(); panel.querySelector("#queue-add").addEventListener("click", addPrompt); - panel.querySelector("#queue-clear").addEventListener("click", processor.clearQueue); + panel.querySelector("#queue-clear").addEventListener("click", requestClearQueue); panel.querySelector("#queue-pause").addEventListener("click", processor.togglePaused); panel.querySelector("#queue-collapse").addEventListener("click", () => { const body = panel.querySelector("#queue-body"); - const button = panel.querySelector("#queue-collapse"); const hidden = body.style.display === "none"; - body.style.display = hidden ? "block" : "none"; - button.textContent = hidden ? "−" : "+"; - button.title = hidden ? "Свернуть" : "Развернуть"; - }); - panel.querySelector("#queue-input").addEventListener("keydown", (event) => { - if (event.ctrlKey && event.key === "Enter") { - addPrompt(); - } + const collapsed = !hidden; + updateCollapsedState(collapsed); + storage.setItem(COLLAPSED_KEY, String(collapsed)); }); + panel.querySelector("#queue-input").addEventListener("keydown", handleInputKeydown); store.subscribe(renderQueue); window.addEventListener("resize", keepPanelInsideViewport); updatePauseButton(); + updateCollapsedState( + storage.getItem(COLLAPSED_KEY) !== "false" + ); renderQueue(); mounted = true; } @@ -763,6 +1075,8 @@ var TRANSITION_GRACE = 4e3; var RETRY_DELAY = 3e3; var MAX_SEND_ATTEMPTS = 3; + var EDITING_STATUS = "editing"; + var EDITING_ERROR_STATUS = "editing_error"; var QUEUE_STATE = Object.freeze({ IDLE: "idle", WAITING_FOR_CHAT: "waiting_for_chat", @@ -869,6 +1183,7 @@ let currentChatIdentity = adapter.getChatIdentity?.() || null; let followerStatusShown = false; let hadLeadership = false; + let scopeMigrationInFlight = false; function savePaused() { storage.setItem(PAUSED_KEY, String(paused)); } @@ -967,6 +1282,7 @@ store.save(); } activeItemId = null; + scopeMigrationInFlight = false; retryAt = 0; responseStartedObserved = false; forceQueueState( @@ -982,8 +1298,10 @@ store.save(); } activeItemId = null; + scopeMigrationInFlight = false; } function handleAttemptFailure(item, composer, message) { + scopeMigrationInFlight = false; item = store.findById(item.id) || item; adapter.clearComposerIfOwned(composer, item.text); adapter.endQueueSend?.(); @@ -1014,7 +1332,7 @@ ); } async function attemptQueueItem(item) { - if (!store.findById(item.id) || item.status === "error") { + if (!store.findById(item.id) || item.status !== "pending") { activeItemId = null; transitionQueueState(QUEUE_STATE.IDLE); return; @@ -1073,6 +1391,14 @@ return; } item = currentItem; + if (item.status !== "preparing") { + adapter.clearComposerIfOwned(composer, item.text); + activeItemId = null; + forceQueueState( + paused ? QUEUE_STATE.PAUSED : QUEUE_STATE.IDLE + ); + return; + } if (sendButtonResult.reason === "paused") { abortPreparedItem(item, composer); transitionQueueState( @@ -1108,7 +1434,7 @@ return; } const snapshot = adapter.capturePreSendSnapshot(composer); - if (!hasLeadership() || attemptChatIdentity && adapter.getChatIdentity?.() !== attemptChatIdentity) { + if (!hasLeadership() || item.status !== "preparing" || attemptChatIdentity && adapter.getChatIdentity?.() !== attemptChatIdentity) { resetActiveOperation( hasLeadership() ? "chat_changed" : "leadership_lost" ); @@ -1144,6 +1470,7 @@ item.status = "waiting_for_response"; store.remove(item.id); activeItemId = null; + scopeMigrationInFlight = false; responseStartedObserved = confirmation.processingStarted || adapter.isGenerating(); transitionQueueState( QUEUE_STATE.WAITING_FOR_RESPONSE, @@ -1164,12 +1491,19 @@ } function handleLeadershipChange({ isLeader, - ownerId + ownerId, + reason }) { if (isLeader) { const becameLeader = !hadLeadership; hadLeadership = true; followerStatusShown = false; + if (scopeMigrationInFlight && [ + QUEUE_STATE.SENDING, + QUEUE_STATE.AWAITING_CONFIRMATION + ].includes(queueState)) { + return; + } if (becameLeader) { recoverInterruptedItems(); if (!paused) { @@ -1182,6 +1516,16 @@ } return; } + if (scopeMigrationInFlight && [ + QUEUE_STATE.SENDING, + QUEUE_STATE.AWAITING_CONFIRMATION + ].includes(queueState)) { + hadLeadership = false; + onStatus( + reason === "scope_changed" ? "Чат создан. Переношу очередь без прерывания отправки…" : "Отправка уже началась. Завершаю её проверку без повторения…" + ); + return; + } if (hadLeadership) { resetActiveOperation("leadership_lost"); } @@ -1205,13 +1549,20 @@ return; } if (event.type === "queue_chat_created") { + const nextScope = getQueueScope(event.chatIdentity); + scopeMigrationInFlight = true; + store.migrateToScope?.(nextScope); + void tabLock?.setScope?.(nextScope); currentChatIdentity = event.chatIdentity; return; } if (event.type === "chat_changed") { - currentChatIdentity = event.chatIdentity; holdForTransition(); resetActiveOperation("chat_changed"); + const nextScope = getQueueScope(event.chatIdentity); + store.setScope?.(nextScope); + void tabLock?.setScope?.(nextScope); + currentChatIdentity = event.chatIdentity; onStatus("Чат изменён. Проверяю новое состояние…"); return; } @@ -1497,25 +1848,151 @@ ); processQueue(); } + function beginPromptEdit(id) { + const item = store.findById(id); + if (!item) { + return false; + } + if (item.status === "pending") { + item.status = EDITING_STATUS; + } else if (item.status === "error") { + item.status = EDITING_ERROR_STATUS; + } else if (item.status !== EDITING_STATUS && item.status !== EDITING_ERROR_STATUS) { + return false; + } + if (item.id === activeItemId && queueState === QUEUE_STATE.RETRY_DELAY) { + activeItemId = null; + retryAt = 0; + forceQueueState(QUEUE_STATE.IDLE); + } + store.save(); + onStatus("Редактирование элемента очереди…"); + return true; + } + function finishPromptEdit(id, nextStatus, text = null) { + const item = store.findById(id); + if (!item || ![EDITING_STATUS, EDITING_ERROR_STATUS].includes(item.status)) { + return false; + } + if (text !== null) { + const normalizedText = String(text).trim(); + if (!normalizedText) { + return false; + } + item.text = normalizedText; + } + item.status = nextStatus(item.status); + store.save(); + if (queueState === QUEUE_STATE.WAITING_FOR_STABLE_IDLE) { + stateSince = Date.now(); + } + if (item.status === "pending") { + void processQueue(); + } + return true; + } + function savePromptEdit(id, text) { + return finishPromptEdit( + id, + (status) => status === EDITING_ERROR_STATUS ? "error" : "pending", + text + ); + } + function cancelPromptEdit(id) { + return finishPromptEdit( + id, + (status) => status === EDITING_ERROR_STATUS ? "error" : "pending" + ); + } + function retryPrompt(id) { + const item = store.findById(id); + if (!item || item.status !== "error") { + return false; + } + item.status = "pending"; + item.attempts = 0; + store.save(); + if (queueState === QUEUE_STATE.ERROR) { + transitionQueueState( + QUEUE_STATE.IDLE, + "Элемент возвращён в очередь" + ); + } + void processQueue(); + return true; + } function removePrompt(id) { + const item = store.findById(id); + if (!item) { + return false; + } + if (item.status === "sending") { + onStatus( + "Сообщение уже передано в ChatGPT и не может быть отменено" + ); + return false; + } + if (item.status === "preparing") { + adapter.clearComposerIfOwned( + adapter.findComposer(), + item.text + ); + } if (!store.remove(id)) { - return; + return false; + } + if (id === activeItemId) { + activeItemId = null; + forceQueueState( + paused ? QUEUE_STATE.PAUSED : QUEUE_STATE.IDLE, + "Подготовка сообщения отменена" + ); } if (queueState === QUEUE_STATE.WAITING_FOR_STABLE_IDLE) { stateSince = Date.now(); } + return true; } function movePrompt(id, direction) { + const item = store.findById(id); + if (!item || item.id === activeItemId || ["preparing", "sending"].includes(item.status)) { + return false; + } if (!store.move(id, direction)) { - return; + return false; } if (queueState === QUEUE_STATE.WAITING_FOR_STABLE_IDLE) { stateSince = Date.now(); } + return true; + } + function movePromptRelative(id, targetId, placement) { + const item = store.findById(id); + if (!item || item.id === activeItemId || ["preparing", "sending"].includes(item.status)) { + return false; + } + if (!store.moveRelative(id, targetId, placement)) { + return false; + } + if (queueState === QUEUE_STATE.WAITING_FOR_STABLE_IDLE) { + stateSince = Date.now(); + } + return true; } function clearQueue() { const activeItem = store.findById(activeItemId); - if (activeItem && queueState === QUEUE_STATE.PREPARING) { + if (activeItem?.status === "sending") { + for (const item of [...store.getItems()]) { + if (item.id !== activeItem.id) { + store.remove(item.id); + } + } + onStatus( + "Очередь очищена; текущая отправка остаётся до подтверждения" + ); + return; + } + if (activeItem?.status === "preparing") { adapter.clearComposerIfOwned( adapter.findComposer(), activeItem.text @@ -1525,10 +2002,22 @@ activeItemId = null; onStatus("Очередь очищена"); } + function cancelPendingSendSchedule() { + if (queueState === QUEUE_STATE.RETRY_DELAY) { + retryAt = 0; + activeItemId = null; + } + if (queueState === QUEUE_STATE.WAITING_FOR_STABLE_IDLE) { + stateSince = Date.now(); + } + } function togglePaused() { paused = !paused; savePaused(); onPauseChange(paused); + if (paused) { + cancelPendingSendSchedule(); + } if (paused && ![ QUEUE_STATE.SENDING, QUEUE_STATE.AWAITING_CONFIRMATION @@ -1561,8 +2050,13 @@ handleChatActivity, handleLeadershipChange, addPrompt, + beginPromptEdit, + savePromptEdit, + cancelPromptEdit, + retryPrompt, removePrompt, movePrompt, + movePromptRelative, clearQueue, togglePaused, isPaused: () => paused, @@ -1572,6 +2066,7 @@ // src/queue-store.js var STORAGE_KEY = "chatgpt_prompt_queue_v2"; + var STORAGE_VERSION = 4; function createQueueId() { if (typeof globalThis.crypto?.randomUUID === "function") { return globalThis.crypto.randomUUID(); @@ -1582,6 +2077,9 @@ Math.random().toString(36).slice(2) ].join("-"); } + function normalizeScope(scope) { + return typeof scope === "string" && scope.trim() ? scope.trim() : TEMPORARY_QUEUE_SCOPE; + } function normalizeQueueItem(item, usedIds = /* @__PURE__ */ new Set()) { const source = typeof item === "string" ? { text: item } : item; if (!source || typeof source !== "object" || Array.isArray(source) || typeof source.text !== "string") { @@ -1601,33 +2099,106 @@ attachments: [] }; } - function loadQueue(storage) { + function normalizeQueue(items) { + if (!Array.isArray(items)) { + return []; + } + const usedIds = /* @__PURE__ */ new Set(); + return items.map((item) => normalizeQueueItem(item, usedIds)).filter(Boolean); + } + function emptyDocument() { + return { + version: STORAGE_VERSION, + queues: {}, + revisions: {} + }; + } + function normalizeDocument(saved, legacyScope) { + if (Array.isArray(saved)) { + return { + version: STORAGE_VERSION, + queues: { + [legacyScope]: normalizeQueue(saved) + }, + revisions: {} + }; + } + if (!saved || typeof saved !== "object" || Array.isArray(saved) || !saved.queues || typeof saved.queues !== "object" || Array.isArray(saved.queues)) { + return emptyDocument(); + } + const queues = {}; + const revisions = {}; + for (const [scope, items] of Object.entries(saved.queues)) { + if (typeof scope === "string" && scope.trim() && Array.isArray(items)) { + queues[scope] = normalizeQueue(items); + } + } + if (saved.revisions && typeof saved.revisions === "object" && !Array.isArray(saved.revisions)) { + for (const [scope, revision] of Object.entries(saved.revisions)) { + if (typeof scope === "string" && scope.trim() && Number.isSafeInteger(revision) && revision > 0) { + revisions[scope] = revision; + } + } + } + return { + version: STORAGE_VERSION, + queues, + revisions + }; + } + function readDocument(storage, legacyScope, persistNormalization = false) { + const raw = storage.getItem(STORAGE_KEY); + let saved = null; try { - const raw = storage.getItem(STORAGE_KEY); - const saved = JSON.parse(raw); - if (!Array.isArray(saved)) { - return []; - } - const usedIds = /* @__PURE__ */ new Set(); - const normalized = saved.map((item) => normalizeQueueItem(item, usedIds)).filter(Boolean); - const serialized = JSON.stringify(normalized); + saved = JSON.parse(raw); + } catch { + saved = null; + } + const document2 = normalizeDocument(saved, legacyScope); + if (persistNormalization) { + const serialized = JSON.stringify(document2); if (serialized !== raw) { storage.setItem(STORAGE_KEY, serialized); } - return normalized; - } catch { - return []; } + return document2; + } + function mergeQueues(sourceItems, targetItems) { + const merged = [...sourceItems]; + const usedIds = new Set(sourceItems.map((item) => item.id)); + for (const targetItem of targetItems) { + const existing = merged.find((item) => item.id === targetItem?.id); + if (existing && existing.text === targetItem.text && existing.createdAt === targetItem.createdAt) { + continue; + } + const normalized = normalizeQueueItem(targetItem, usedIds); + if (normalized) { + merged.push(normalized); + } + } + return merged; } - function createQueueStore(storage = localStorage, eventTarget = globalThis.window) { - let queue = loadQueue(storage); + function createQueueStore(storage = localStorage, eventTarget = globalThis.window, initialScope = TEMPORARY_QUEUE_SCOPE) { + let currentScope = normalizeScope(initialScope); + let document2 = readDocument(storage, currentScope, true); + let queue = document2.queues[currentScope] || []; + let currentRevision = document2.revisions[currentScope] || 0; const listeners = /* @__PURE__ */ new Set(); let syncStarted = false; function notify() { listeners.forEach((listener) => listener(queue)); } - function save() { - storage.setItem(STORAGE_KEY, JSON.stringify(queue)); + function persistCurrentQueue() { + const latest = readDocument(storage, currentScope); + const nextRevision = Math.max( + currentRevision, + latest.revisions[currentScope] || 0 + ) + 1; + latest.queues[currentScope] = queue; + latest.revisions[currentScope] = nextRevision; + document2 = latest; + currentRevision = nextRevision; + storage.setItem(STORAGE_KEY, JSON.stringify(document2)); notify(); } function createQueueItem(text) { @@ -1637,8 +2208,8 @@ function add(text) { const item = createQueueItem(text); queue.push(item); - save(); - return item; + persistCurrentQueue(); + return queue.find((queueItem) => queueItem.id === item.id); } function remove(id) { const index = queue.findIndex((item) => item.id === id); @@ -1646,7 +2217,7 @@ return false; } queue.splice(index, 1); - save(); + persistCurrentQueue(); return true; } function move(id, direction) { @@ -1656,12 +2227,79 @@ return false; } [queue[index], queue[target]] = [queue[target], queue[index]]; - save(); + persistCurrentQueue(); + return true; + } + function moveRelative(id, targetId, placement) { + if (id === targetId || !["before", "after"].includes(placement)) { + return false; + } + const sourceIndex = queue.findIndex((item2) => item2.id === id); + const targetIndex = queue.findIndex((item2) => item2.id === targetId); + if (sourceIndex === -1 || targetIndex === -1) { + return false; + } + const previousOrder = queue.map((item2) => item2.id).join("\0"); + const [item] = queue.splice(sourceIndex, 1); + const adjustedTargetIndex = queue.findIndex( + (queueItem) => queueItem.id === targetId + ); + const insertionIndex = placement === "before" ? adjustedTargetIndex : adjustedTargetIndex + 1; + queue.splice(insertionIndex, 0, item); + if (queue.map((queueItem) => queueItem.id).join("\0") === previousOrder) { + return false; + } + persistCurrentQueue(); return true; } function clear() { queue = []; - save(); + persistCurrentQueue(); + } + function setScope(nextScope) { + const normalizedScope = normalizeScope(nextScope); + if (normalizedScope === currentScope) { + return false; + } + currentScope = normalizedScope; + document2 = readDocument(storage, currentScope, true); + queue = document2.queues[currentScope] || []; + currentRevision = document2.revisions[currentScope] || 0; + notify(); + return true; + } + function migrateToScope(nextScope) { + const targetScope = normalizeScope(nextScope); + if (targetScope === currentScope) { + return false; + } + const sourceScope = currentScope; + const latest = readDocument(storage, sourceScope); + const sourceItems = mergeQueues( + queue, + latest.queues[sourceScope] || [] + ); + const targetItems = latest.queues[targetScope] || []; + latest.queues[targetScope] = mergeQueues(sourceItems, targetItems); + latest.revisions[targetScope] = Math.max( + currentRevision, + latest.revisions[sourceScope] || 0, + latest.revisions[targetScope] || 0 + ) + 1; + delete latest.queues[sourceScope]; + latest.revisions[sourceScope] = latest.revisions[targetScope] + 1; + currentScope = targetScope; + document2 = latest; + queue = document2.queues[currentScope]; + currentRevision = document2.revisions[currentScope]; + storage.setItem(STORAGE_KEY, JSON.stringify(document2)); + notify(); + logInfo("Временная очередь перенесена в созданный чат", { + sourceScope, + targetScope, + itemCount: queue.length + }); + return true; } function subscribe(listener) { listeners.add(listener); @@ -1671,9 +2309,27 @@ if (event.key !== STORAGE_KEY) { return; } - queue = loadQueue(storage); + const incoming = readDocument(storage, currentScope, true); + const incomingRevision = incoming.revisions[currentScope] || 0; + if (incomingRevision < currentRevision) { + incoming.queues[currentScope] = queue; + incoming.revisions[currentScope] = currentRevision; + document2 = incoming; + storage.setItem(STORAGE_KEY, JSON.stringify(document2)); + logInfo("Восстановлена очередь после устаревшей записи другого чата", { + scope: currentScope, + revision: currentRevision, + staleRevision: incomingRevision + }); + return; + } + document2 = incoming; + currentRevision = incomingRevision; + queue = document2.queues[currentScope] || []; notify(); - logInfo("Очередь синхронизирована из другой вкладки"); + logInfo("Очередь текущего чата синхронизирована из другой вкладки", { + scope: currentScope + }); } function startSync() { if (syncStarted || !eventTarget?.addEventListener) { @@ -1691,13 +2347,17 @@ } return { getItems: () => queue, + getScope: () => currentScope, findById: (id) => queue.find((item) => item.id === id) || null, - findNextProcessable: () => queue.find((item) => item.status !== "error") || null, + findNextProcessable: () => queue.find((item) => item.status === "pending") || null, add, remove, move, + moveRelative, clear, - save, + save: persistCurrentQueue, + setScope, + migrateToScope, subscribe, startSync, stopSync @@ -1788,6 +2448,15 @@ background: #444654; } + #chatgpt-queue-panel button:disabled { + opacity: 0.42; + cursor: not-allowed; + } + + #chatgpt-queue-panel button:disabled:hover { + background: #343541; + } + #queue-pause.queue-paused { background: #66521b; border-color: #a78324; @@ -1800,25 +2469,162 @@ } .queue-item { - display: flex; + position: relative; + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; align-items: flex-start; - justify-content: space-between; - gap: 8px; - padding: 8px 0; - border-top: 1px solid #444; + gap: 6px; + margin-top: 4px; + padding: 7px 6px; + border: 1px solid transparent; + border-radius: 8px; + background: rgba(255, 255, 255, 0.025); + transition: + opacity 140ms ease, + border-color 140ms ease, + background 140ms ease; + } + + .queue-item-pending { + opacity: 0.62; + } + + .queue-item-pending:hover { + opacity: 0.82; + } + + .queue-item-active { + opacity: 1; + border-color: rgba(86, 156, 255, 0.88); + background: rgba(48, 112, 205, 0.2); + box-shadow: inset 3px 0 rgba(86, 156, 255, 0.95); + } + + .queue-item-error { + opacity: 1; + border-color: rgba(235, 87, 87, 0.9); + background: rgba(150, 38, 38, 0.24); + box-shadow: inset 3px 0 rgba(235, 87, 87, 0.95); + } + + .queue-item-editing { + opacity: 0.94; + border-color: rgba(218, 178, 70, 0.82); + background: rgba(126, 96, 24, 0.18); + } + + .queue-item-unknown { + opacity: 0.78; + } + + .queue-item-dragging { + opacity: 0.3; + } + + .queue-item-drag-active, + .queue-item-drag-active * { + cursor: grabbing !important; + user-select: none; + } + + .queue-drop-before::before, + .queue-drop-after::after { + position: absolute; + right: 3px; + left: 3px; + height: 3px; + border-radius: 3px; + background: #65a6ff; + box-shadow: 0 0 7px rgba(101, 166, 255, 0.9); + content: ''; + } + + .queue-drop-before::before { + top: -4px; + } + + .queue-drop-after::after { + bottom: -4px; + } + + #chatgpt-queue-panel .queue-item-drag-handle { + width: 24px; + padding: 4px 2px; + color: #aaa; + cursor: grab; + user-select: none; + } + + #chatgpt-queue-panel .queue-item-drag-handle:active { + cursor: grabbing; + } + + .queue-item-content { + min-width: 0; + } + + .queue-item-meta { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 3px; + color: #aaa; + font-size: 10px; + } + + .queue-item-number { + font-variant-numeric: tabular-nums; + } + + .queue-item-status { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .queue-item-text { - flex: 1; overflow-wrap: anywhere; white-space: pre-wrap; font-size: 13px; } + .queue-item-editor { + box-sizing: border-box; + width: 100%; + min-height: 58px; + resize: vertical; + padding: 5px 6px; + border: 1px solid #777; + border-radius: 6px; + background: rgba(25, 26, 29, 0.95); + color: #fff; + font: inherit; + font-size: 12px; + } + + .queue-item-editor[aria-invalid='true'] { + border-color: #eb5757; + box-shadow: 0 0 0 1px #eb5757; + } + .queue-item-controls { display: flex; + max-width: 112px; + flex-wrap: wrap; + justify-content: flex-end; gap: 3px; } + + #chatgpt-queue-panel .queue-item-controls button { + padding: 3px 5px; + font-size: 11px; + line-height: 1.2; + } + + #chatgpt-queue-panel .queue-item-retry { + border-color: rgba(235, 87, 87, 0.9); + background: rgba(150, 38, 38, 0.55); + } `; function installStyles() { if (installed) { @@ -1836,6 +2642,12 @@ var DEFAULT_TTL = 8e3; var DEFAULT_RENEW_INTERVAL = 2e3; var DEFAULT_SETTLE_DELAY = 100; + function getScopedTabLockKey(scope) { + return scope === TEMPORARY_QUEUE_SCOPE ? TAB_LOCK_KEY : `${TAB_LOCK_KEY}:${encodeURIComponent(scope)}`; + } + function getScopedChannelName(scope) { + return `${CHANNEL_NAME}:${encodeURIComponent(scope)}`; + } function createId(prefix) { const value = typeof globalThis.crypto?.randomUUID === "function" ? globalThis.crypto.randomUUID() : [ Date.now().toString(36), @@ -1851,7 +2663,8 @@ random = () => Math.random(), ttl = DEFAULT_TTL, renewInterval = DEFAULT_RENEW_INTERVAL, - settleDelay = DEFAULT_SETTLE_DELAY + settleDelay = DEFAULT_SETTLE_DELAY, + scope = TEMPORARY_QUEUE_SCOPE } = {}) { const tabId = createId("tab"); const listeners = /* @__PURE__ */ new Set(); @@ -1862,9 +2675,13 @@ let channel = null; let renewalTimer = null; let acquiringPromise = null; - function readLease() { + let currentScope = scope; + let scopeEpoch = 0; + function readLease(targetScope = currentScope) { try { - const lease = JSON.parse(storage.getItem(TAB_LOCK_KEY)); + const lease = JSON.parse( + storage.getItem(getScopedTabLockKey(targetScope)) + ); if (!lease || typeof lease.ownerId !== "string" || typeof lease.token !== "string" || typeof lease.expiresAt !== "number") { return null; } @@ -1876,16 +2693,16 @@ function isValid(lease, timestamp = now()) { return Boolean(lease && lease.expiresAt > timestamp); } - function isOwnedLease(lease) { + function isOwnedLease(lease, token = claimToken) { return Boolean( - lease && lease.ownerId === tabId && lease.token === claimToken + lease && lease.ownerId === tabId && lease.token === token ); } - function notifyLeadership(nextLeader, nextOwnerId = null) { + function notifyLeadership(nextLeader, nextOwnerId = null, reason = "lease_changed", force = false) { const changed = leader !== nextLeader || ownerId !== nextOwnerId; leader = nextLeader; ownerId = nextOwnerId; - if (!changed) { + if (!changed && !force) { return; } logInfo( @@ -1899,16 +2716,19 @@ listener({ isLeader: nextLeader, ownerId: nextOwnerId, - tabId + tabId, + scope: currentScope, + reason }); }); } - function broadcast(type) { + function broadcast(type, targetScope = currentScope) { channel?.postMessage({ type, ownerId: tabId, token: claimToken, - timestamp: now() + timestamp: now(), + scope: targetScope }); } function verifyOwnership() { @@ -1926,6 +2746,8 @@ if (!started) { return false; } + const acquisitionScope = currentScope; + const acquisitionEpoch = scopeEpoch; if (verifyOwnership()) { return true; } @@ -1937,24 +2759,31 @@ await sleep( settleDelay + Math.floor(random() * settleDelay) ); - const beforeWrite = readLease(); + if (acquisitionEpoch !== scopeEpoch) { + return false; + } + const beforeWrite = readLease(acquisitionScope); if (isValid(beforeWrite) && beforeWrite.ownerId !== tabId) { notifyLeadership(false, beforeWrite.ownerId); return false; } - claimToken = createId("claim"); + const nextClaimToken = createId("claim"); + claimToken = nextClaimToken; storage.setItem( - TAB_LOCK_KEY, + getScopedTabLockKey(acquisitionScope), JSON.stringify({ ownerId: tabId, - token: claimToken, + token: nextClaimToken, expiresAt: now() + ttl }) ); - broadcast("claim"); + broadcast("claim", acquisitionScope); await sleep(settleDelay); - const confirmed = readLease(); - if (isValid(confirmed) && isOwnedLease(confirmed)) { + if (acquisitionEpoch !== scopeEpoch) { + return false; + } + const confirmed = readLease(acquisitionScope); + if (isValid(confirmed) && isOwnedLease(confirmed, nextClaimToken)) { notifyLeadership(true, tabId); return true; } @@ -1966,9 +2795,13 @@ } function acquire() { if (!acquiringPromise) { - acquiringPromise = acquireLease().finally(() => { - acquiringPromise = null; + const pending = acquireLease(); + const tracked = pending.finally(() => { + if (acquiringPromise === tracked) { + acquiringPromise = null; + } }); + acquiringPromise = tracked; } return acquiringPromise; } @@ -1976,7 +2809,7 @@ const lease = readLease(); if (isValid(lease) && isOwnedLease(lease)) { storage.setItem( - TAB_LOCK_KEY, + getScopedTabLockKey(currentScope), JSON.stringify({ ownerId: tabId, token: claimToken, @@ -1999,14 +2832,16 @@ void acquire(); } } - function release() { + function release({ notify = true } = {}) { const lease = readLease(); if (isOwnedLease(lease)) { - storage.removeItem(TAB_LOCK_KEY); + storage.removeItem(getScopedTabLockKey(currentScope)); broadcast("release"); } claimToken = null; - notifyLeadership(false, null); + if (notify) { + notifyLeadership(false, null, "released"); + } } function reconcile() { const lease = readLease(); @@ -2023,22 +2858,55 @@ } } function handleStorage(event) { - if (event.key === TAB_LOCK_KEY) { + if (event.key === getScopedTabLockKey(currentScope)) { reconcile(); } } function handleChannelMessage(event) { - if (event.data?.type === "claim" || event.data?.type === "heartbeat" || event.data?.type === "release") { + if (event.data?.scope === currentScope && [ + "claim", + "heartbeat", + "release" + ].includes(event.data?.type)) { reconcile(); } } + function openChannel() { + channel = channelFactory(getScopedChannelName(currentScope)); + channel?.addEventListener("message", handleChannelMessage); + } + function closeChannel() { + channel?.removeEventListener("message", handleChannelMessage); + channel?.close(); + channel = null; + } + function setScope(nextScope) { + if (!nextScope || nextScope === currentScope) { + return Promise.resolve(verifyOwnership()); + } + const previousScope = currentScope; + release({ notify: false }); + closeChannel(); + currentScope = nextScope; + scopeEpoch += 1; + acquiringPromise = null; + notifyLeadership(false, null, "scope_changed", true); + logInfo("Блокировка очереди переключена на другой чат", { + previousScope, + scope: currentScope + }); + if (!started) { + return Promise.resolve(false); + } + openChannel(); + return acquire(); + } function start() { if (started) { return; } started = true; - channel = channelFactory(CHANNEL_NAME); - channel?.addEventListener("message", handleChannelMessage); + openChannel(); eventTarget?.addEventListener("storage", handleStorage); eventTarget?.addEventListener("pagehide", release); eventTarget?.addEventListener("beforeunload", release); @@ -2052,9 +2920,7 @@ release(); clearInterval(renewalTimer); renewalTimer = null; - channel?.removeEventListener("message", handleChannelMessage); - channel?.close(); - channel = null; + closeChannel(); eventTarget?.removeEventListener("storage", handleStorage); eventTarget?.removeEventListener("pagehide", release); eventTarget?.removeEventListener("beforeunload", release); @@ -2069,19 +2935,27 @@ stop, acquire, release, + setScope, subscribe, verifyOwnership, isLeader: () => leader, getOwnerId: () => ownerId, - getTabId: () => tabId + getTabId: () => tabId, + getScope: () => currentScope, + getLockKey: () => getScopedTabLockKey(currentScope) }; } // src/main.js function main() { - const store = createQueueStore(); const adapter = createChatGptAdapter(); - const tabLock = createTabLock(); + const initialScope = getQueueScope(adapter.getChatIdentity()); + const store = createQueueStore( + localStorage, + window, + initialScope + ); + const tabLock = createTabLock({ scope: initialScope }); let panel = null; const processor = createQueueProcessor({ store, diff --git a/package.json b/package.json index ea122f7..df6a868 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ ], "scripts": { "build": "node build.mjs", - "check": "node --check build.mjs && node --check src/main.js && node --check src/chatgpt-adapter.js && node --check src/panel-ui.js && node --check src/queue-processor.js && node --check src/queue-store.js && node --check src/styles.js && node --check src/tab-lock.js && node --check src/utils.js", + "check": "node --check build.mjs && node --check src/main.js && node --check src/chat-scope.js && node --check src/chatgpt-adapter.js && node --check src/panel-ui.js && node --check src/queue-processor.js && node --check src/queue-store.js && node --check src/styles.js && node --check src/tab-lock.js && node --check src/utils.js", "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "dev": "node build.mjs --watch", "rebuild": "npm run clean && npm run build", diff --git a/src/chat-scope.js b/src/chat-scope.js new file mode 100644 index 0000000..011dcc3 --- /dev/null +++ b/src/chat-scope.js @@ -0,0 +1,40 @@ +/** + * Converts ChatGPT routes into stable persistence and leadership scopes. + * Keeping this mapping pure ensures the store, processor, and tab lock cannot + * disagree about which tabs belong to the same conversation. + */ + +export const TEMPORARY_QUEUE_SCOPE = 'temporary'; + +/** + * Returns the stable queue scope represented by a ChatGPT route identity. + * + * Query strings do not affect conversation ownership. `/c/` routes share + * a scope across tabs, while the home route uses one temporary scope until + * ChatGPT assigns an id after the first send. Unknown routes are isolated by + * path instead of falling back to another conversation's queue. + * + * @param {string|null|undefined} chatIdentity - Path and optional query string + * reported by the ChatGPT adapter. + * @returns {string} Stable scope suitable for persistence and lease keys. + */ +export function getQueueScope(chatIdentity) { + const path = String(chatIdentity || '/') + .split(/[?#]/, 1)[0] + .replace(/\/+$/, '') || '/'; + const chatMatch = path.match(/^\/c\/([^/]+)$/); + + if (chatMatch) { + try { + return `chat:${decodeURIComponent(chatMatch[1])}`; + } catch { + return `chat:${chatMatch[1]}`; + } + } + + if (path === '/') { + return TEMPORARY_QUEUE_SCOPE; + } + + return `route:${path}`; +} diff --git a/src/main.js b/src/main.js index d3ffea7..e810643 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,5 @@ import { createChatGptAdapter } from './chatgpt-adapter.js'; +import { getQueueScope } from './chat-scope.js'; import { createPanelUi } from './panel-ui.js'; import { createQueueProcessor } from './queue-processor.js'; import { createQueueStore } from './queue-store.js'; @@ -11,9 +12,14 @@ import { createTabLock } from './tab-lock.js'; */ function main() { - const store = createQueueStore(); const adapter = createChatGptAdapter(); - const tabLock = createTabLock(); + const initialScope = getQueueScope(adapter.getChatIdentity()); + const store = createQueueStore( + localStorage, + window, + initialScope + ); + const tabLock = createTabLock({ scope: initialScope }); let panel = null; const processor = createQueueProcessor({ diff --git a/src/panel-ui.js b/src/panel-ui.js index 46df832..84a4d40 100644 --- a/src/panel-ui.js +++ b/src/panel-ui.js @@ -4,6 +4,17 @@ */ const POSITION_KEY = 'chatgpt_prompt_queue_position_v1'; +const COLLAPSED_KEY = 'chatgpt_prompt_queue_collapsed_v1'; +const EDITING_STATUSES = new Set(['editing', 'editing_error']); + +const STATUS_VIEW = Object.freeze({ + pending: { className: 'pending', label: 'Ожидает' }, + editing: { className: 'editing', label: 'Редактируется' }, + editing_error: { className: 'editing', label: 'Редактируется' }, + preparing: { className: 'active', label: 'Подготовка' }, + sending: { className: 'active', label: 'Отправляется' }, + error: { className: 'error', label: 'Ошибка' } +}); /** * Creates the floating panel controller while keeping mounting explicit. @@ -23,7 +34,9 @@ const POSITION_KEY = 'chatgpt_prompt_queue_position_v1'; * @param {object} dependencies.processor - Queue command API used by panel * controls. * @param {Storage} [dependencies.storage=localStorage] - Storage used only for - * panel position; queue data remains owned by the store. + * panel position and collapsed state; queue data remains owned by the store. + * @param {Function} [dependencies.confirmAction=window.confirm] - Synchronous + * confirmation boundary used before clearing a non-empty queue. * @returns {{ * mount: () => void, * renderQueue: () => void, @@ -34,10 +47,16 @@ const POSITION_KEY = 'chatgpt_prompt_queue_position_v1'; export function createPanelUi({ store, processor, - storage = localStorage + storage = localStorage, + confirmAction = message => window.confirm(message) }) { let panel = null; let mounted = false; + let editingItemId = null; + let editDraft = ''; + let draggedItemId = null; + let dropTargetId = null; + let dropPlacement = null; function createButton(text, handler) { const button = document.createElement('button'); @@ -46,6 +65,11 @@ export function createPanelUi({ return button; } + function setButtonLabel(button, label) { + button.title = label; + button.setAttribute('aria-label', label); + } + function updateStatus(text) { const status = document.querySelector('#queue-status'); @@ -62,13 +86,198 @@ export function createPanelUi({ } button.textContent = paused ? '▶' : '⏸'; - button.title = paused + const label = paused ? 'Продолжить очередь' : 'Приостановить очередь'; + setButtonLabel(button, label); + button.classList.toggle('queue-paused', paused); } + function getStatusView(status) { + return STATUS_VIEW[status] || { + className: 'unknown', + label: status || 'Неизвестно' + }; + } + + function findRenderedItem(id) { + return Array.from( + document.querySelectorAll('#queue-list .queue-item') + ).find(item => item.dataset.queueId === id) || null; + } + + function clearDropIndicator() { + document + .querySelectorAll( + '.queue-drop-before, .queue-drop-after' + ) + .forEach(item => { + item.classList.remove( + 'queue-drop-before', + 'queue-drop-after' + ); + }); + + dropTargetId = null; + dropPlacement = null; + } + + function resetDragState() { + clearDropIndicator(); + + if (draggedItemId) { + findRenderedItem(draggedItemId) + ?.classList.remove('queue-item-dragging'); + } + + draggedItemId = null; + document.body.classList.remove('queue-item-drag-active'); + } + + function startEditing(prompt) { + // Persisting the editing status is the cross-tab barrier that prevents + // a leader tab from selecting this item while another tab edits it. + editingItemId = prompt.id; + editDraft = prompt.text; + + if (!processor.beginPromptEdit(prompt.id)) { + editingItemId = null; + editDraft = ''; + renderQueue(); + return; + } + + findRenderedItem(prompt.id) + ?.querySelector('.queue-item-editor') + ?.focus(); + } + + function saveEditing() { + const id = editingItemId; + const text = editDraft.trim(); + + if (!id || !text) { + findRenderedItem(id) + ?.querySelector('.queue-item-editor') + ?.setAttribute('aria-invalid', 'true'); + return; + } + + editingItemId = null; + + if (!processor.savePromptEdit(id, text)) { + editingItemId = id; + renderQueue(); + return; + } + + editDraft = ''; + } + + function cancelEditing() { + const id = editingItemId; + + if (!id) { + return; + } + + editingItemId = null; + editDraft = ''; + + if (!processor.cancelPromptEdit(id)) { + renderQueue(); + } + } + + function createDragHandle(prompt, movable) { + const handle = createButton('⋮⋮', () => {}); + handle.className = 'queue-item-drag-handle'; + handle.title = movable + ? 'Перетащить сообщение' + : 'Это сообщение сейчас нельзя перемещать'; + setButtonLabel(handle, handle.title); + handle.disabled = !movable; + + handle.addEventListener('mousedown', event => { + if (!movable || event.button !== 0) { + return; + } + + clearDropIndicator(); + draggedItemId = prompt.id; + findRenderedItem(prompt.id) + ?.classList.add('queue-item-dragging'); + document.body.classList.add('queue-item-drag-active'); + event.preventDefault(); + event.stopPropagation(); + }); + + return handle; + } + + function enableQueueItemDragging() { + // Native HTML5 drag events are unreliable when the drag source is an + // interactive button. A document-level mouse gesture keeps the handle + // as the only entry point while still supporting precise before/after + // insertion and the existing button-based fallback. + document.addEventListener('mousemove', event => { + if (!draggedItemId) { + return; + } + + const item = document + .elementFromPoint(event.clientX, event.clientY) + ?.closest('.queue-item'); + const targetId = item?.dataset.queueId; + + if (!item || !targetId || targetId === draggedItemId) { + clearDropIndicator(); + return; + } + + const rect = item.getBoundingClientRect(); + const placement = event.clientY < rect.top + rect.height / 2 + ? 'before' + : 'after'; + + if ( + dropTargetId === targetId && + dropPlacement === placement + ) { + return; + } + + clearDropIndicator(); + dropTargetId = targetId; + dropPlacement = placement; + item.classList.add(`queue-drop-${placement}`); + }); + + document.addEventListener('mouseup', () => { + if (!draggedItemId) { + return; + } + + const sourceId = draggedItemId; + const targetId = dropTargetId; + const placement = dropPlacement; + + resetDragState(); + + if (targetId && placement) { + processor.movePromptRelative( + sourceId, + targetId, + placement + ); + } + }); + + window.addEventListener('blur', resetDragState); + } + function renderQueue() { const list = document.querySelector('#queue-list'); @@ -76,38 +285,148 @@ export function createPanelUi({ return; } + const prompts = store.getItems(); + const editedItem = editingItemId + ? store.findById(editingItemId) + : null; + + if ( + editingItemId && + ( + !editedItem || + !EDITING_STATUSES.has(editedItem.status) + ) + ) { + editingItemId = null; + editDraft = ''; + } + list.innerHTML = ''; - store.getItems().forEach((prompt, index) => { + prompts.forEach((prompt, index) => { + const statusView = getStatusView(prompt.status); + const editing = editingItemId === prompt.id; + const movable = !editing && ![ + 'preparing', + 'sending' + ].includes(prompt.status); const item = document.createElement('div'); - item.className = 'queue-item'; + item.className = [ + 'queue-item', + `queue-item-${statusView.className}` + ].join(' '); + item.dataset.queueId = prompt.id; + + const handle = createDragHandle(prompt, movable); - const text = document.createElement('div'); - text.className = 'queue-item-text'; - text.textContent = `${index + 1}. ${prompt.text}`; + const content = document.createElement('div'); + content.className = 'queue-item-content'; + + const meta = document.createElement('div'); + meta.className = 'queue-item-meta'; + + const number = document.createElement('span'); + number.className = 'queue-item-number'; + number.textContent = `#${index + 1}`; + + const status = document.createElement('span'); + status.className = 'queue-item-status'; + status.textContent = statusView.label; + + if (prompt.status === 'error' && prompt.attempts > 0) { + status.textContent += ` · ${prompt.attempts}`; + } + + meta.append(number, status); + content.appendChild(meta); + + if (editing) { + const editor = document.createElement('textarea'); + editor.className = 'queue-item-editor'; + editor.value = editDraft; + editor.rows = 3; + editor.addEventListener('input', () => { + editDraft = editor.value; + editor.removeAttribute('aria-invalid'); + + const save = item.querySelector( + '.queue-item-save' + ); + + if (save) { + save.disabled = !editDraft.trim(); + } + }); + content.appendChild(editor); + } else { + const text = document.createElement('div'); + text.className = 'queue-item-text'; + text.textContent = prompt.text; + content.appendChild(text); + } const controls = document.createElement('div'); controls.className = 'queue-item-controls'; - const up = createButton( - '↑', - () => processor.movePrompt(prompt.id, -1) - ); - const down = createButton( - '↓', - () => processor.movePrompt(prompt.id, 1) - ); - const remove = createButton( - '×', - () => processor.removePrompt(prompt.id) - ); + if (editing) { + const save = createButton('Save', saveEditing); + save.className = 'queue-item-save'; + save.disabled = !editDraft.trim(); + setButtonLabel(save, 'Сохранить изменения'); + + const cancel = createButton('Cancel', cancelEditing); + setButtonLabel(cancel, 'Отменить редактирование'); + controls.append(save, cancel); + } else { + const up = createButton( + '↑', + () => processor.movePrompt(prompt.id, -1) + ); + setButtonLabel(up, 'Переместить сообщение вверх'); + up.disabled = !movable || index === 0; + + const down = createButton( + '↓', + () => processor.movePrompt(prompt.id, 1) + ); + setButtonLabel(down, 'Переместить сообщение вниз'); + down.disabled = !movable || index === prompts.length - 1; + + const edit = createButton( + 'Edit', + () => startEditing(prompt) + ); + setButtonLabel(edit, 'Редактировать сообщение'); + edit.disabled = !movable; + + if (prompt.status === 'error') { + const retry = createButton( + 'Retry', + () => processor.retryPrompt(prompt.id) + ); + retry.className = 'queue-item-retry'; + setButtonLabel(retry, 'Повторить отправку сообщения'); + controls.append(retry); + } + + const remove = createButton('×', () => { + if (editingItemId === prompt.id) { + editingItemId = null; + editDraft = ''; + } - controls.append(up, down, remove); - item.append(text, controls); + processor.removePrompt(prompt.id); + }); + setButtonLabel(remove, 'Удалить сообщение'); + + controls.append(up, down, edit, remove); + } + + item.append(handle, content, controls); list.appendChild(item); }); - if (store.getItems().length === 0) { + if (prompts.length === 0) { list.textContent = 'Очередь пуста'; } } @@ -240,11 +559,84 @@ export function createPanelUi({ const text = input.value.trim(); if (!text) { + input.focus(); return; } - input.value = ''; processor.addPrompt(text); + input.value = ''; + input.focus(); + } + + function insertInputNewline(input) { + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? start; + + input.setRangeText('\n', start, end, 'end'); + input.dispatchEvent(new Event('input', { bubbles: true })); + } + + function handleInputKeydown(event) { + if (!['Enter', 'Process'].includes(event.key)) { + return; + } + + // keyCode 229 and `Process` cover browsers that expose an active IME + // composition without consistently setting `isComposing`. + if ( + event.isComposing || + event.keyCode === 229 || + event.key === 'Process' || + event.altKey || + event.metaKey + ) { + return; + } + + if (event.ctrlKey) { + event.preventDefault(); + insertInputNewline(event.currentTarget); + return; + } + + if (event.shiftKey || editingItemId) { + return; + } + + event.preventDefault(); + addPrompt(); + } + + function requestClearQueue() { + const prompts = store.getItems(); + + if (prompts.length === 0) { + return; + } + + const sendAlreadyStarted = + prompts.some(prompt => prompt.status === 'sending') || + [ + 'sending', + 'awaiting_confirmation' + ].includes(processor.getState?.()); + const message = sendAlreadyStarted + ? 'Одно сообщение уже передано в ChatGPT и отменить его нельзя. ' + + 'Очистить остальные сообщения очереди?' + : `Удалить все сообщения из очереди (${prompts.length})?`; + + if (confirmAction(message)) { + processor.clearQueue(); + } + } + + function updateCollapsedState(collapsed) { + const body = panel.querySelector('#queue-body'); + const button = panel.querySelector('#queue-collapse'); + + body.style.display = collapsed ? 'none' : 'block'; + button.textContent = collapsed ? '+' : '−'; + setButtonLabel(button, collapsed ? 'Развернуть' : 'Свернуть'); } function mount() { @@ -260,8 +652,16 @@ export function createPanelUi({ Очередь ChatGPT
- - + +
@@ -272,8 +672,16 @@ export function createPanelUi({ >
- - + +
Готово
@@ -285,13 +693,14 @@ export function createPanelUi({ restorePanelPosition(panel); enableDragging(panel); + enableQueueItemDragging(); panel .querySelector('#queue-add') .addEventListener('click', addPrompt); panel .querySelector('#queue-clear') - .addEventListener('click', processor.clearQueue); + .addEventListener('click', requestClearQueue); panel .querySelector('#queue-pause') .addEventListener('click', processor.togglePaused); @@ -299,25 +708,23 @@ export function createPanelUi({ .querySelector('#queue-collapse') .addEventListener('click', () => { const body = panel.querySelector('#queue-body'); - const button = panel.querySelector('#queue-collapse'); const hidden = body.style.display === 'none'; + const collapsed = !hidden; - body.style.display = hidden ? 'block' : 'none'; - button.textContent = hidden ? '−' : '+'; - button.title = hidden ? 'Свернуть' : 'Развернуть'; + updateCollapsedState(collapsed); + storage.setItem(COLLAPSED_KEY, String(collapsed)); }); panel .querySelector('#queue-input') - .addEventListener('keydown', event => { - if (event.ctrlKey && event.key === 'Enter') { - addPrompt(); - } - }); + .addEventListener('keydown', handleInputKeydown); store.subscribe(renderQueue); window.addEventListener('resize', keepPanelInsideViewport); updatePauseButton(); + updateCollapsedState( + storage.getItem(COLLAPSED_KEY) !== 'false' + ); renderQueue(); mounted = true; } diff --git a/src/queue-processor.js b/src/queue-processor.js index c782aa1..a340b3f 100644 --- a/src/queue-processor.js +++ b/src/queue-processor.js @@ -1,4 +1,5 @@ import { logInfo, logWarn, sleep } from './utils.js'; +import { getQueueScope } from './chat-scope.js'; /** * Coordinates QueueItems, ChatGPT DOM state, retries, and tab leadership. @@ -13,6 +14,8 @@ const STABLE_IDLE_DELAY = 2500; const TRANSITION_GRACE = 4000; const RETRY_DELAY = 3000; const MAX_SEND_ATTEMPTS = 3; +const EDITING_STATUS = 'editing'; +const EDITING_ERROR_STATUS = 'editing_error'; /** Public state names are persisted/displayed contracts and remain stable. */ export const QUEUE_STATE = Object.freeze({ @@ -189,6 +192,7 @@ export function createQueueProcessor({ adapter.getChatIdentity?.() || null; let followerStatusShown = false; let hadLeadership = false; + let scopeMigrationInFlight = false; function savePaused() { storage.setItem(PAUSED_KEY, String(paused)); @@ -317,6 +321,7 @@ export function createQueueProcessor({ } activeItemId = null; + scopeMigrationInFlight = false; retryAt = 0; responseStartedObserved = false; @@ -336,9 +341,11 @@ export function createQueueProcessor({ } activeItemId = null; + scopeMigrationInFlight = false; } function handleAttemptFailure(item, composer, message) { + scopeMigrationInFlight = false; item = store.findById(item.id) || item; adapter.clearComposerIfOwned(composer, item.text); adapter.endQueueSend?.(); @@ -375,7 +382,7 @@ export function createQueueProcessor({ } async function attemptQueueItem(item) { - if (!store.findById(item.id) || item.status === 'error') { + if (!store.findById(item.id) || item.status !== 'pending') { activeItemId = null; transitionQueueState(QUEUE_STATE.IDLE); return; @@ -455,6 +462,15 @@ export function createQueueProcessor({ item = currentItem; + if (item.status !== 'preparing') { + adapter.clearComposerIfOwned(composer, item.text); + activeItemId = null; + forceQueueState( + paused ? QUEUE_STATE.PAUSED : QUEUE_STATE.IDLE + ); + return; + } + if (sendButtonResult.reason === 'paused') { abortPreparedItem(item, composer); transitionQueueState( @@ -504,6 +520,7 @@ export function createQueueProcessor({ // click; both may change while waiting for ChatGPT to enable Send. if ( !hasLeadership() || + item.status !== 'preparing' || ( attemptChatIdentity && adapter.getChatIdentity?.() !== @@ -559,6 +576,7 @@ export function createQueueProcessor({ item.status = 'waiting_for_response'; store.remove(item.id); activeItemId = null; + scopeMigrationInFlight = false; responseStartedObserved = confirmation.processingStarted || adapter.isGenerating(); @@ -585,7 +603,8 @@ export function createQueueProcessor({ function handleLeadershipChange({ isLeader, - ownerId + ownerId, + reason }) { if (isLeader) { const becameLeader = !hadLeadership; @@ -593,6 +612,16 @@ export function createQueueProcessor({ hadLeadership = true; followerStatusShown = false; + if ( + scopeMigrationInFlight && + [ + QUEUE_STATE.SENDING, + QUEUE_STATE.AWAITING_CONFIRMATION + ].includes(queueState) + ) { + return; + } + if (becameLeader) { recoverInterruptedItems(); @@ -608,6 +637,25 @@ export function createQueueProcessor({ return; } + // ChatGPT assigns `/c/` only after the irreversible click. The + // corresponding scope handover must not invalidate confirmation of + // that same send, even though the temporary lease is released. + if ( + scopeMigrationInFlight && + [ + QUEUE_STATE.SENDING, + QUEUE_STATE.AWAITING_CONFIRMATION + ].includes(queueState) + ) { + hadLeadership = false; + onStatus( + reason === 'scope_changed' + ? 'Чат создан. Переношу очередь без прерывания отправки…' + : 'Отправка уже началась. Завершаю её проверку без повторения…' + ); + return; + } + if (hadLeadership) { resetActiveOperation('leadership_lost'); } @@ -641,14 +689,23 @@ export function createQueueProcessor({ // Creating a chat changes the URL before ChatGPT confirms the first // queued send. The adapter has already proven this route is queue-owned. if (event.type === 'queue_chat_created') { + const nextScope = getQueueScope(event.chatIdentity); + + scopeMigrationInFlight = true; + store.migrateToScope?.(nextScope); + void tabLock?.setScope?.(nextScope); currentChatIdentity = event.chatIdentity; return; } if (event.type === 'chat_changed') { - currentChatIdentity = event.chatIdentity; holdForTransition(); resetActiveOperation('chat_changed'); + const nextScope = getQueueScope(event.chatIdentity); + + store.setScope?.(nextScope); + void tabLock?.setScope?.(nextScope); + currentChatIdentity = event.chatIdentity; onStatus('Чат изменён. Проверяю новое состояние…'); return; } @@ -1044,30 +1101,215 @@ export function createQueueProcessor({ processQueue(); } + function beginPromptEdit(id) { + const item = store.findById(id); + + if (!item) { + return false; + } + + if (item.status === 'pending') { + item.status = EDITING_STATUS; + } else if (item.status === 'error') { + item.status = EDITING_ERROR_STATUS; + } else if ( + item.status !== EDITING_STATUS && + item.status !== EDITING_ERROR_STATUS + ) { + return false; + } + + if ( + item.id === activeItemId && + queueState === QUEUE_STATE.RETRY_DELAY + ) { + activeItemId = null; + retryAt = 0; + forceQueueState(QUEUE_STATE.IDLE); + } + + store.save(); + onStatus('Редактирование элемента очереди…'); + return true; + } + + function finishPromptEdit(id, nextStatus, text = null) { + const item = store.findById(id); + + if ( + !item || + ![EDITING_STATUS, EDITING_ERROR_STATUS] + .includes(item.status) + ) { + return false; + } + + if (text !== null) { + const normalizedText = String(text).trim(); + + if (!normalizedText) { + return false; + } + + item.text = normalizedText; + } + + item.status = nextStatus(item.status); + store.save(); + + if (queueState === QUEUE_STATE.WAITING_FOR_STABLE_IDLE) { + stateSince = Date.now(); + } + + if (item.status === 'pending') { + void processQueue(); + } + + return true; + } + + function savePromptEdit(id, text) { + return finishPromptEdit( + id, + status => status === EDITING_ERROR_STATUS + ? 'error' + : 'pending', + text + ); + } + + function cancelPromptEdit(id) { + return finishPromptEdit( + id, + status => status === EDITING_ERROR_STATUS + ? 'error' + : 'pending' + ); + } + + function retryPrompt(id) { + const item = store.findById(id); + + if (!item || item.status !== 'error') { + return false; + } + + item.status = 'pending'; + item.attempts = 0; + store.save(); + + if (queueState === QUEUE_STATE.ERROR) { + transitionQueueState( + QUEUE_STATE.IDLE, + 'Элемент возвращён в очередь' + ); + } + + void processQueue(); + return true; + } + function removePrompt(id) { + const item = store.findById(id); + + if (!item) { + return false; + } + + if (item.status === 'sending') { + onStatus( + 'Сообщение уже передано в ChatGPT и не может быть отменено' + ); + return false; + } + + if (item.status === 'preparing') { + adapter.clearComposerIfOwned( + adapter.findComposer(), + item.text + ); + } + if (!store.remove(id)) { - return; + return false; + } + + if (id === activeItemId) { + activeItemId = null; + forceQueueState( + paused ? QUEUE_STATE.PAUSED : QUEUE_STATE.IDLE, + 'Подготовка сообщения отменена' + ); } if (queueState === QUEUE_STATE.WAITING_FOR_STABLE_IDLE) { stateSince = Date.now(); } + + return true; } function movePrompt(id, direction) { + const item = store.findById(id); + + if ( + !item || + item.id === activeItemId || + ['preparing', 'sending'].includes(item.status) + ) { + return false; + } + if (!store.move(id, direction)) { - return; + return false; + } + + if (queueState === QUEUE_STATE.WAITING_FOR_STABLE_IDLE) { + stateSince = Date.now(); + } + + return true; + } + + function movePromptRelative(id, targetId, placement) { + const item = store.findById(id); + + if ( + !item || + item.id === activeItemId || + ['preparing', 'sending'].includes(item.status) + ) { + return false; + } + + if (!store.moveRelative(id, targetId, placement)) { + return false; } if (queueState === QUEUE_STATE.WAITING_FOR_STABLE_IDLE) { stateSince = Date.now(); } + + return true; } function clearQueue() { const activeItem = store.findById(activeItemId); - if (activeItem && queueState === QUEUE_STATE.PREPARING) { + if (activeItem?.status === 'sending') { + for (const item of [...store.getItems()]) { + if (item.id !== activeItem.id) { + store.remove(item.id); + } + } + + onStatus( + 'Очередь очищена; текущая отправка остаётся до подтверждения' + ); + return; + } + + if (activeItem?.status === 'preparing') { adapter.clearComposerIfOwned( adapter.findComposer(), activeItem.text @@ -1079,11 +1321,28 @@ export function createQueueProcessor({ onStatus('Очередь очищена'); } + function cancelPendingSendSchedule() { + if (queueState === QUEUE_STATE.RETRY_DELAY) { + retryAt = 0; + activeItemId = null; + } + + if (queueState === QUEUE_STATE.WAITING_FOR_STABLE_IDLE) { + // Resume must earn a new full stable-idle window instead of using + // time that elapsed while the queue was paused. + stateSince = Date.now(); + } + } + function togglePaused() { paused = !paused; savePaused(); onPauseChange(paused); + if (paused) { + cancelPendingSendSchedule(); + } + if ( paused && ![ @@ -1122,8 +1381,13 @@ export function createQueueProcessor({ handleChatActivity, handleLeadershipChange, addPrompt, + beginPromptEdit, + savePromptEdit, + cancelPromptEdit, + retryPrompt, removePrompt, movePrompt, + movePromptRelative, clearQueue, togglePaused, isPaused: () => paused, diff --git a/src/queue-store.js b/src/queue-store.js index 4fe5739..6a17a5d 100644 --- a/src/queue-store.js +++ b/src/queue-store.js @@ -1,12 +1,14 @@ +import { TEMPORARY_QUEUE_SCOPE } from './chat-scope.js'; import { logInfo } from './utils.js'; /** - * Owns QueueItem persistence, legacy migration, and cross-tab synchronization. - * No other module parses or rewrites the queue's localStorage representation. + * Owns per-chat QueueItem persistence, legacy migration, and cross-tab sync. + * No other module parses or rewrites the queue localStorage document. */ /** Existing queue key; changing it would orphan users' persisted prompts. */ export const STORAGE_KEY = 'chatgpt_prompt_queue_v2'; +export const STORAGE_VERSION = 4; function createQueueId() { if (typeof globalThis.crypto?.randomUUID === 'function') { @@ -20,13 +22,15 @@ function createQueueId() { ].join('-'); } -/** - * Converts legacy strings and partial objects to the current QueueItem shape. - */ +function normalizeScope(scope) { + return typeof scope === 'string' && scope.trim() + ? scope.trim() + : TEMPORARY_QUEUE_SCOPE; +} + +/** Converts legacy strings and partial objects to the current QueueItem shape. */ function normalizeQueueItem(item, usedIds = new Set()) { - const source = typeof item === 'string' - ? { text: item } - : item; + const source = typeof item === 'string' ? { text: item } : item; if ( !source || @@ -37,9 +41,7 @@ function normalizeQueueItem(item, usedIds = new Set()) { return null; } - let id = typeof source.id === 'string' - ? source.id.trim() - : ''; + let id = typeof source.id === 'string' ? source.id.trim() : ''; while (!id || usedIds.has(id)) { id = createQueueId(); @@ -68,76 +70,182 @@ function normalizeQueueItem(item, usedIds = new Set()) { }; } -function loadQueue(storage) { - try { - const raw = storage.getItem(STORAGE_KEY); - const saved = JSON.parse(raw); +function normalizeQueue(items) { + if (!Array.isArray(items)) { + return []; + } + + const usedIds = new Set(); + return items + .map(item => normalizeQueueItem(item, usedIds)) + .filter(Boolean); +} + +function emptyDocument() { + return { + version: STORAGE_VERSION, + queues: {}, + revisions: {} + }; +} + +function normalizeDocument(saved, legacyScope) { + if (Array.isArray(saved)) { + return { + version: STORAGE_VERSION, + queues: { + [legacyScope]: normalizeQueue(saved) + }, + revisions: {} + }; + } + + if ( + !saved || + typeof saved !== 'object' || + Array.isArray(saved) || + !saved.queues || + typeof saved.queues !== 'object' || + Array.isArray(saved.queues) + ) { + return emptyDocument(); + } + + const queues = {}; + const revisions = {}; + + for (const [scope, items] of Object.entries(saved.queues)) { + if (typeof scope === 'string' && scope.trim() && Array.isArray(items)) { + queues[scope] = normalizeQueue(items); + } + } - if (!Array.isArray(saved)) { - return []; + if ( + saved.revisions && + typeof saved.revisions === 'object' && + !Array.isArray(saved.revisions) + ) { + for (const [scope, revision] of Object.entries(saved.revisions)) { + if ( + typeof scope === 'string' && + scope.trim() && + Number.isSafeInteger(revision) && + revision > 0 + ) { + revisions[scope] = revision; + } } + } - const usedIds = new Set(); - const normalized = saved - .map(item => normalizeQueueItem(item, usedIds)) - .filter(Boolean); - const serialized = JSON.stringify(normalized); + return { + version: STORAGE_VERSION, + queues, + revisions + }; +} + +function readDocument(storage, legacyScope, persistNormalization = false) { + const raw = storage.getItem(STORAGE_KEY); + let saved = null; + + try { + saved = JSON.parse(raw); + } catch { + saved = null; + } + + const document = normalizeDocument(saved, legacyScope); + + if (persistNormalization) { + const serialized = JSON.stringify(document); - // Persist migrations immediately so every tab observes one canonical - // QueueItem shape, including stable and collision-free ids. if (serialized !== raw) { storage.setItem(STORAGE_KEY, serialized); } + } - return normalized; - } catch { - return []; + return document; +} + +function mergeQueues(sourceItems, targetItems) { + const merged = [...sourceItems]; + const usedIds = new Set(sourceItems.map(item => item.id)); + + for (const targetItem of targetItems) { + const existing = merged.find(item => item.id === targetItem?.id); + + if ( + existing && + existing.text === targetItem.text && + existing.createdAt === targetItem.createdAt + ) { + continue; + } + + const normalized = normalizeQueueItem(targetItem, usedIds); + + if (normalized) { + merged.push(normalized); + } } + + return merged; } /** - * Creates the single in-memory owner of the persisted queue. + * Creates the in-memory view of one queue scope within the shared document. * - * Loading is also a migration boundary: legacy strings, damaged partial - * objects, and duplicate ids are normalized to the current QueueItem shape - * before any caller can observe them. Mutations are persisted synchronously, - * and cross-tab storage events replace the complete local snapshot rather than - * merging object references from different tabs. + * The existing array schema is migrated into `initialScope` on first load. + * Every save rereads the document and replaces only the active scope, which + * prevents a tab in one chat from overwriting a newer queue in another chat. + * Scope migration moves the temporary queue and switches the live view in one + * localStorage write, preserving the active QueueItem object and its status. * - * Side effects: construction reads the queue key and may rewrite it when - * normalization changes the serialized value. Mutating methods write to - * storage and notify subscribers synchronously. `startSync()` installs one - * storage listener; `stopSync()` removes it. + * Side effects: construction reads and may normalize `STORAGE_KEY`. Mutations + * synchronously persist and notify subscribers. `startSync()` installs one + * storage listener and `stopSync()` removes it. * - * Guarantees: ids are unique within the loaded queue, attachments remain an - * empty array at this feature stage, and the existing storage key is preserved. - * `getItems()` intentionally exposes the live array because the processor - * updates item statuses in place; callers must invoke `save()` after doing so. + * Guarantees: QueueItem ids are unique within each queue, attachments remain + * empty, different scopes never appear through `getItems()`, and the original + * storage key remains authoritative. Mutating a returned item still requires + * `save()` so processor status transitions remain explicit. * - * @param {Storage} [storage=localStorage] - Persistence backend implementing - * the Web Storage interface. - * @param {Window|EventTarget} [eventTarget=window] - Source of cross-document - * storage events; may be omitted in tests or non-browser environments. + * @param {Storage} [storage=localStorage] - Web Storage persistence backend. + * @param {Window|EventTarget} [eventTarget=window] - Storage-event source. + * @param {string} [initialScope=temporary] - Queue visible at construction; + * legacy global data is assigned here during migration. * @returns {{ * getItems: () => Array, + * getScope: () => string, * findById: (id: string) => object|null, * findNextProcessable: () => object|null, * add: (text: string) => object, * remove: (id: string) => boolean, * move: (id: string, direction: number) => boolean, + * moveRelative: ( + * id: string, + * targetId: string, + * placement: 'before'|'after' + * ) => boolean, * clear: () => void, * save: () => void, + * setScope: (scope: string) => boolean, + * migrateToScope: (scope: string) => boolean, * subscribe: (listener: Function) => Function, * startSync: () => void, * stopSync: () => void - * }} Store API. Subscription returns an unsubscribe function; failed remove or - * move operations return `false` without persisting. + * }} Store API. Scope changes notify subscribers synchronously; failed + * mutations return `false` without writing storage. */ export function createQueueStore( storage = localStorage, - eventTarget = globalThis.window + eventTarget = globalThis.window, + initialScope = TEMPORARY_QUEUE_SCOPE ) { - let queue = loadQueue(storage); + let currentScope = normalizeScope(initialScope); + let document = readDocument(storage, currentScope, true); + let queue = document.queues[currentScope] || []; + let currentRevision = document.revisions[currentScope] || 0; const listeners = new Set(); let syncStarted = false; @@ -145,8 +253,18 @@ export function createQueueStore( listeners.forEach(listener => listener(queue)); } - function save() { - storage.setItem(STORAGE_KEY, JSON.stringify(queue)); + function persistCurrentQueue() { + const latest = readDocument(storage, currentScope); + const nextRevision = Math.max( + currentRevision, + latest.revisions[currentScope] || 0 + ) + 1; + + latest.queues[currentScope] = queue; + latest.revisions[currentScope] = nextRevision; + document = latest; + currentRevision = nextRevision; + storage.setItem(STORAGE_KEY, JSON.stringify(document)); notify(); } @@ -158,8 +276,8 @@ export function createQueueStore( function add(text) { const item = createQueueItem(text); queue.push(item); - save(); - return item; + persistCurrentQueue(); + return queue.find(queueItem => queueItem.id === item.id); } function remove(id) { @@ -170,7 +288,7 @@ export function createQueueStore( } queue.splice(index, 1); - save(); + persistCurrentQueue(); return true; } @@ -183,13 +301,99 @@ export function createQueueStore( } [queue[index], queue[target]] = [queue[target], queue[index]]; - save(); + persistCurrentQueue(); + return true; + } + + function moveRelative(id, targetId, placement) { + if (id === targetId || !['before', 'after'].includes(placement)) { + return false; + } + + const sourceIndex = queue.findIndex(item => item.id === id); + const targetIndex = queue.findIndex(item => item.id === targetId); + + if (sourceIndex === -1 || targetIndex === -1) { + return false; + } + + const previousOrder = queue.map(item => item.id).join('\u0000'); + const [item] = queue.splice(sourceIndex, 1); + const adjustedTargetIndex = queue.findIndex( + queueItem => queueItem.id === targetId + ); + const insertionIndex = placement === 'before' + ? adjustedTargetIndex + : adjustedTargetIndex + 1; + + queue.splice(insertionIndex, 0, item); + + if (queue.map(queueItem => queueItem.id).join('\u0000') === previousOrder) { + return false; + } + + persistCurrentQueue(); return true; } function clear() { queue = []; - save(); + persistCurrentQueue(); + } + + function setScope(nextScope) { + const normalizedScope = normalizeScope(nextScope); + + if (normalizedScope === currentScope) { + return false; + } + + currentScope = normalizedScope; + document = readDocument(storage, currentScope, true); + queue = document.queues[currentScope] || []; + currentRevision = document.revisions[currentScope] || 0; + notify(); + return true; + } + + function migrateToScope(nextScope) { + const targetScope = normalizeScope(nextScope); + + if (targetScope === currentScope) { + return false; + } + + const sourceScope = currentScope; + const latest = readDocument(storage, sourceScope); + const sourceItems = mergeQueues( + queue, + latest.queues[sourceScope] || [] + ); + const targetItems = latest.queues[targetScope] || []; + + latest.queues[targetScope] = mergeQueues(sourceItems, targetItems); + latest.revisions[targetScope] = Math.max( + currentRevision, + latest.revisions[sourceScope] || 0, + latest.revisions[targetScope] || 0 + ) + 1; + delete latest.queues[sourceScope]; + latest.revisions[sourceScope] = + latest.revisions[targetScope] + 1; + + currentScope = targetScope; + document = latest; + queue = document.queues[currentScope]; + currentRevision = document.revisions[currentScope]; + storage.setItem(STORAGE_KEY, JSON.stringify(document)); + notify(); + + logInfo('Временная очередь перенесена в созданный чат', { + sourceScope, + targetScope, + itemCount: queue.length + }); + return true; } function subscribe(listener) { @@ -202,9 +406,31 @@ export function createQueueStore( return; } - queue = loadQueue(storage); + const incoming = readDocument(storage, currentScope, true); + const incomingRevision = + incoming.revisions[currentScope] || 0; + + if (incomingRevision < currentRevision) { + incoming.queues[currentScope] = queue; + incoming.revisions[currentScope] = currentRevision; + document = incoming; + storage.setItem(STORAGE_KEY, JSON.stringify(document)); + + logInfo('Восстановлена очередь после устаревшей записи другого чата', { + scope: currentScope, + revision: currentRevision, + staleRevision: incomingRevision + }); + return; + } + + document = incoming; + currentRevision = incomingRevision; + queue = document.queues[currentScope] || []; notify(); - logInfo('Очередь синхронизирована из другой вкладки'); + logInfo('Очередь текущего чата синхронизирована из другой вкладки', { + scope: currentScope + }); } function startSync() { @@ -227,14 +453,18 @@ export function createQueueStore( return { getItems: () => queue, + getScope: () => currentScope, findById: id => queue.find(item => item.id === id) || null, findNextProcessable: () => - queue.find(item => item.status !== 'error') || null, + queue.find(item => item.status === 'pending') || null, add, remove, move, + moveRelative, clear, - save, + save: persistCurrentQueue, + setScope, + migrateToScope, subscribe, startSync, stopSync diff --git a/src/styles.js b/src/styles.js index 8489d01..a964311 100644 --- a/src/styles.js +++ b/src/styles.js @@ -87,6 +87,15 @@ const PANEL_CSS = ` background: #444654; } + #chatgpt-queue-panel button:disabled { + opacity: 0.42; + cursor: not-allowed; + } + + #chatgpt-queue-panel button:disabled:hover { + background: #343541; + } + #queue-pause.queue-paused { background: #66521b; border-color: #a78324; @@ -99,25 +108,162 @@ const PANEL_CSS = ` } .queue-item { - display: flex; + position: relative; + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; align-items: flex-start; - justify-content: space-between; - gap: 8px; - padding: 8px 0; - border-top: 1px solid #444; + gap: 6px; + margin-top: 4px; + padding: 7px 6px; + border: 1px solid transparent; + border-radius: 8px; + background: rgba(255, 255, 255, 0.025); + transition: + opacity 140ms ease, + border-color 140ms ease, + background 140ms ease; + } + + .queue-item-pending { + opacity: 0.62; + } + + .queue-item-pending:hover { + opacity: 0.82; + } + + .queue-item-active { + opacity: 1; + border-color: rgba(86, 156, 255, 0.88); + background: rgba(48, 112, 205, 0.2); + box-shadow: inset 3px 0 rgba(86, 156, 255, 0.95); + } + + .queue-item-error { + opacity: 1; + border-color: rgba(235, 87, 87, 0.9); + background: rgba(150, 38, 38, 0.24); + box-shadow: inset 3px 0 rgba(235, 87, 87, 0.95); + } + + .queue-item-editing { + opacity: 0.94; + border-color: rgba(218, 178, 70, 0.82); + background: rgba(126, 96, 24, 0.18); + } + + .queue-item-unknown { + opacity: 0.78; + } + + .queue-item-dragging { + opacity: 0.3; + } + + .queue-item-drag-active, + .queue-item-drag-active * { + cursor: grabbing !important; + user-select: none; + } + + .queue-drop-before::before, + .queue-drop-after::after { + position: absolute; + right: 3px; + left: 3px; + height: 3px; + border-radius: 3px; + background: #65a6ff; + box-shadow: 0 0 7px rgba(101, 166, 255, 0.9); + content: ''; + } + + .queue-drop-before::before { + top: -4px; + } + + .queue-drop-after::after { + bottom: -4px; + } + + #chatgpt-queue-panel .queue-item-drag-handle { + width: 24px; + padding: 4px 2px; + color: #aaa; + cursor: grab; + user-select: none; + } + + #chatgpt-queue-panel .queue-item-drag-handle:active { + cursor: grabbing; + } + + .queue-item-content { + min-width: 0; + } + + .queue-item-meta { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 3px; + color: #aaa; + font-size: 10px; + } + + .queue-item-number { + font-variant-numeric: tabular-nums; + } + + .queue-item-status { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .queue-item-text { - flex: 1; overflow-wrap: anywhere; white-space: pre-wrap; font-size: 13px; } + .queue-item-editor { + box-sizing: border-box; + width: 100%; + min-height: 58px; + resize: vertical; + padding: 5px 6px; + border: 1px solid #777; + border-radius: 6px; + background: rgba(25, 26, 29, 0.95); + color: #fff; + font: inherit; + font-size: 12px; + } + + .queue-item-editor[aria-invalid='true'] { + border-color: #eb5757; + box-shadow: 0 0 0 1px #eb5757; + } + .queue-item-controls { display: flex; + max-width: 112px; + flex-wrap: wrap; + justify-content: flex-end; gap: 3px; } + + #chatgpt-queue-panel .queue-item-controls button { + padding: 3px 5px; + font-size: 11px; + line-height: 1.2; + } + + #chatgpt-queue-panel .queue-item-retry { + border-color: rgba(235, 87, 87, 0.9); + background: rgba(150, 38, 38, 0.55); + } `; /** diff --git a/src/tab-lock.js b/src/tab-lock.js index 935a9f0..fa71236 100644 --- a/src/tab-lock.js +++ b/src/tab-lock.js @@ -1,4 +1,5 @@ import { logInfo, logWarn, sleep } from './utils.js'; +import { TEMPORARY_QUEUE_SCOPE } from './chat-scope.js'; /** * Coordinates queue leadership across ChatGPT tabs. @@ -14,6 +15,24 @@ const DEFAULT_TTL = 8000; const DEFAULT_RENEW_INTERVAL = 2000; const DEFAULT_SETTLE_DELAY = 100; +/** + * Returns the localStorage lease key for one queue scope. + * The temporary queue retains the historical key; chat leases use suffixes so + * unrelated conversations can elect leaders independently. + * + * @param {string} scope - Queue scope returned by `getQueueScope()`. + * @returns {string} Scope-specific lease key. + */ +export function getScopedTabLockKey(scope) { + return scope === TEMPORARY_QUEUE_SCOPE + ? TAB_LOCK_KEY + : `${TAB_LOCK_KEY}:${encodeURIComponent(scope)}`; +} + +function getScopedChannelName(scope) { + return `${CHANNEL_NAME}:${encodeURIComponent(scope)}`; +} + function createId(prefix) { const value = typeof globalThis.crypto?.randomUUID === 'function' ? globalThis.crypto.randomUUID() @@ -67,6 +86,8 @@ function createId(prefix) { * @param {number} [options.renewInterval=2000] - Renewal cadence in * milliseconds; it should remain comfortably below `ttl`. * @param {number} [options.settleDelay=100] - Base contention/read-back delay. + * @param {string} [options.scope=temporary] - Queue scope whose lease this + * instance currently contests; it may be changed with `setScope()`. * @returns {object} API for start/stop, acquire/release, synchronous ownership * verification, leadership subscription, and tab/owner identity inspection. */ @@ -82,7 +103,8 @@ export function createTabLock({ random = () => Math.random(), ttl = DEFAULT_TTL, renewInterval = DEFAULT_RENEW_INTERVAL, - settleDelay = DEFAULT_SETTLE_DELAY + settleDelay = DEFAULT_SETTLE_DELAY, + scope = TEMPORARY_QUEUE_SCOPE } = {}) { const tabId = createId('tab'); const listeners = new Set(); @@ -94,10 +116,14 @@ export function createTabLock({ let channel = null; let renewalTimer = null; let acquiringPromise = null; + let currentScope = scope; + let scopeEpoch = 0; - function readLease() { + function readLease(targetScope = currentScope) { try { - const lease = JSON.parse(storage.getItem(TAB_LOCK_KEY)); + const lease = JSON.parse( + storage.getItem(getScopedTabLockKey(targetScope)) + ); if ( !lease || @@ -118,15 +144,20 @@ export function createTabLock({ return Boolean(lease && lease.expiresAt > timestamp); } - function isOwnedLease(lease) { + function isOwnedLease(lease, token = claimToken) { return Boolean( lease && lease.ownerId === tabId && - lease.token === claimToken + lease.token === token ); } - function notifyLeadership(nextLeader, nextOwnerId = null) { + function notifyLeadership( + nextLeader, + nextOwnerId = null, + reason = 'lease_changed', + force = false + ) { const changed = leader !== nextLeader || ownerId !== nextOwnerId; @@ -134,7 +165,7 @@ export function createTabLock({ leader = nextLeader; ownerId = nextOwnerId; - if (!changed) { + if (!changed && !force) { return; } @@ -152,17 +183,20 @@ export function createTabLock({ listener({ isLeader: nextLeader, ownerId: nextOwnerId, - tabId + tabId, + scope: currentScope, + reason }); }); } - function broadcast(type) { + function broadcast(type, targetScope = currentScope) { channel?.postMessage({ type, ownerId: tabId, token: claimToken, - timestamp: now() + timestamp: now(), + scope: targetScope }); } @@ -185,6 +219,9 @@ export function createTabLock({ return false; } + const acquisitionScope = currentScope; + const acquisitionEpoch = scopeEpoch; + if (verifyOwnership()) { return true; } @@ -203,7 +240,11 @@ export function createTabLock({ Math.floor(random() * settleDelay) ); - const beforeWrite = readLease(); + if (acquisitionEpoch !== scopeEpoch) { + return false; + } + + const beforeWrite = readLease(acquisitionScope); if ( isValid(beforeWrite) && @@ -213,25 +254,33 @@ export function createTabLock({ return false; } - claimToken = createId('claim'); + const nextClaimToken = createId('claim'); + claimToken = nextClaimToken; storage.setItem( - TAB_LOCK_KEY, + getScopedTabLockKey(acquisitionScope), JSON.stringify({ ownerId: tabId, - token: claimToken, + token: nextClaimToken, expiresAt: now() + ttl }) ); - broadcast('claim'); + broadcast('claim', acquisitionScope); // Last-writer-wins can still replace our claim. Never announce // leadership until the exact fencing token survives a settle window. await sleep(settleDelay); - const confirmed = readLease(); + if (acquisitionEpoch !== scopeEpoch) { + return false; + } + + const confirmed = readLease(acquisitionScope); - if (isValid(confirmed) && isOwnedLease(confirmed)) { + if ( + isValid(confirmed) && + isOwnedLease(confirmed, nextClaimToken) + ) { notifyLeadership(true, tabId); return true; } @@ -245,9 +294,13 @@ export function createTabLock({ function acquire() { if (!acquiringPromise) { - acquiringPromise = acquireLease().finally(() => { - acquiringPromise = null; + const pending = acquireLease(); + const tracked = pending.finally(() => { + if (acquiringPromise === tracked) { + acquiringPromise = null; + } }); + acquiringPromise = tracked; } return acquiringPromise; @@ -258,7 +311,7 @@ export function createTabLock({ if (isValid(lease) && isOwnedLease(lease)) { storage.setItem( - TAB_LOCK_KEY, + getScopedTabLockKey(currentScope), JSON.stringify({ ownerId: tabId, token: claimToken, @@ -285,18 +338,21 @@ export function createTabLock({ } } - function release() { + function release({ notify = true } = {}) { const lease = readLease(); // A delayed pagehide from an old leader must not remove a newer tab's // lease; release is fenced by the claim token, not only the tab id. if (isOwnedLease(lease)) { - storage.removeItem(TAB_LOCK_KEY); + storage.removeItem(getScopedTabLockKey(currentScope)); broadcast('release'); } claimToken = null; - notifyLeadership(false, null); + + if (notify) { + notifyLeadership(false, null, 'released'); + } } function reconcile() { @@ -318,29 +374,69 @@ export function createTabLock({ } function handleStorage(event) { - if (event.key === TAB_LOCK_KEY) { + if (event.key === getScopedTabLockKey(currentScope)) { reconcile(); } } function handleChannelMessage(event) { if ( - event.data?.type === 'claim' || - event.data?.type === 'heartbeat' || - event.data?.type === 'release' + event.data?.scope === currentScope && + [ + 'claim', + 'heartbeat', + 'release' + ].includes(event.data?.type) ) { reconcile(); } } + function openChannel() { + channel = channelFactory(getScopedChannelName(currentScope)); + channel?.addEventListener('message', handleChannelMessage); + } + + function closeChannel() { + channel?.removeEventListener('message', handleChannelMessage); + channel?.close(); + channel = null; + } + + function setScope(nextScope) { + if (!nextScope || nextScope === currentScope) { + return Promise.resolve(verifyOwnership()); + } + + const previousScope = currentScope; + + release({ notify: false }); + closeChannel(); + currentScope = nextScope; + scopeEpoch += 1; + acquiringPromise = null; + notifyLeadership(false, null, 'scope_changed', true); + + logInfo('Блокировка очереди переключена на другой чат', { + previousScope, + scope: currentScope + }); + + if (!started) { + return Promise.resolve(false); + } + + openChannel(); + return acquire(); + } + function start() { if (started) { return; } started = true; - channel = channelFactory(CHANNEL_NAME); - channel?.addEventListener('message', handleChannelMessage); + openChannel(); eventTarget?.addEventListener('storage', handleStorage); eventTarget?.addEventListener('pagehide', release); eventTarget?.addEventListener('beforeunload', release); @@ -357,9 +453,7 @@ export function createTabLock({ release(); clearInterval(renewalTimer); renewalTimer = null; - channel?.removeEventListener('message', handleChannelMessage); - channel?.close(); - channel = null; + closeChannel(); eventTarget?.removeEventListener('storage', handleStorage); eventTarget?.removeEventListener('pagehide', release); eventTarget?.removeEventListener('beforeunload', release); @@ -376,10 +470,13 @@ export function createTabLock({ stop, acquire, release, + setScope, subscribe, verifyOwnership, isLeader: () => leader, getOwnerId: () => ownerId, - getTabId: () => tabId + getTabId: () => tabId, + getScope: () => currentScope, + getLockKey: () => getScopedTabLockKey(currentScope) }; } diff --git a/test/chat-scope.test.js b/test/chat-scope.test.js new file mode 100644 index 0000000..2147647 --- /dev/null +++ b/test/chat-scope.test.js @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { + getQueueScope, + TEMPORARY_QUEUE_SCOPE +} from '../src/chat-scope.js'; + +describe('Chat queue scope', () => { + it('maps tabs of one chat to one stable scope', () => { + expect(getQueueScope('/c/chat-123')).toBe('chat:chat-123'); + expect(getQueueScope('/c/chat-123?model=gpt-5')).toBe( + 'chat:chat-123' + ); + expect(getQueueScope('/c/%broken')).toBe('chat:%broken'); + }); + + it('isolates different chats and the temporary home queue', () => { + expect(getQueueScope('/')).toBe(TEMPORARY_QUEUE_SCOPE); + expect(getQueueScope('/?temporary-chat=false')).toBe( + TEMPORARY_QUEUE_SCOPE + ); + expect(getQueueScope('/c/alpha')).not.toBe( + getQueueScope('/c/beta') + ); + }); +}); diff --git a/test/e2e/panel-actions.html b/test/e2e/panel-actions.html new file mode 100644 index 0000000..76e8cac --- /dev/null +++ b/test/e2e/panel-actions.html @@ -0,0 +1,205 @@ + + + + + Panel actions E2E + + + +

Panel actions E2E

+
Выполняются сценарии…
+ + + + diff --git a/test/e2e/per-chat-frame.html b/test/e2e/per-chat-frame.html new file mode 100644 index 0000000..fb54f17 --- /dev/null +++ b/test/e2e/per-chat-frame.html @@ -0,0 +1,90 @@ + + +Per-chat queue E2E frame + diff --git a/test/e2e/per-chat-queues.html b/test/e2e/per-chat-queues.html new file mode 100644 index 0000000..e79fd42 --- /dev/null +++ b/test/e2e/per-chat-queues.html @@ -0,0 +1,192 @@ + + + + + Per-chat queues E2E + + + +

Per-chat queues E2E

+
Выполняются сценарии…
+
+ + + + diff --git a/test/helpers/fake-chatgpt-adapter.js b/test/helpers/fake-chatgpt-adapter.js index b97945c..c291574 100644 --- a/test/helpers/fake-chatgpt-adapter.js +++ b/test/helpers/fake-chatgpt-adapter.js @@ -1,6 +1,7 @@ import { vi } from 'vitest'; export function createFakeChatGptAdapter({ + chatIdentity = '/c/test-chat', confirmations = [ { confirmed: true, @@ -15,7 +16,7 @@ export function createFakeChatGptAdapter({ composer, button, sendConfirmationTimeout: 12000, - getChatIdentity: vi.fn(() => '/c/test-chat'), + getChatIdentity: vi.fn(() => chatIdentity), inspectActivity: vi.fn(), cancelPendingOperations: vi.fn(), beginQueueSend: vi.fn(), diff --git a/test/queue-processor.test.js b/test/queue-processor.test.js index e1fe75f..c05b429 100644 --- a/test/queue-processor.test.js +++ b/test/queue-processor.test.js @@ -11,6 +11,7 @@ import { QUEUE_STATE } from '../src/queue-processor.js'; import { createQueueStore } from '../src/queue-store.js'; +import { getQueueScope } from '../src/chat-scope.js'; import { createFakeChatGptAdapter } from './helpers/fake-chatgpt-adapter.js'; import { createMemoryStorage } from './helpers/memory-storage.js'; @@ -40,7 +41,11 @@ function createHarness({ adapter = createFakeChatGptAdapter(), tabLock = null } = {}) { - const store = createQueueStore(storage); + const store = createQueueStore( + storage, + null, + getQueueScope(adapter.getChatIdentity()) + ); const onStatus = vi.fn(); const onPauseChange = vi.fn(); const processor = createQueueProcessor({ @@ -232,6 +237,37 @@ describe('QueueProcessor', () => { expect(store.findById(item.id)).not.toBeNull(); }); + it('restarts the send delay after pausing a scheduled prompt', async () => { + const { adapter, processor, store } = createHarness(); + store.add('wait through pause'); + + await processor.processQueue(); + expect(processor.getState()).toBe( + QUEUE_STATE.WAITING_FOR_STABLE_IDLE + ); + + await vi.advanceTimersByTimeAsync(SEND_DELAY - 100); + processor.togglePaused(); + await vi.advanceTimersByTimeAsync(10000); + + expect(processor.getState()).toBe(QUEUE_STATE.PAUSED); + expect(adapter.button.click).not.toHaveBeenCalled(); + + processor.togglePaused(); + await settleMicrotasks(); + await vi.advanceTimersByTimeAsync(SEND_DELAY - 1); + await processor.processQueue(); + + expect(adapter.button.click).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + const attempt = processor.processQueue(); + await vi.advanceTimersByTimeAsync(PREPARE_DELAY); + await attempt; + + expect(adapter.button.click).toHaveBeenCalledOnce(); + }); + it('waits for stable idle after Stop and a regeneration gap', async () => { const { adapter, processor, store } = createHarness(); store.add('after regeneration'); @@ -307,7 +343,7 @@ describe('QueueProcessor', () => { confirmSend = resolve; }) ); - const { processor, store } = createHarness({ adapter }); + const { processor, storage, store } = createHarness({ adapter }); const item = store.add('do not duplicate'); await processor.processQueue(); @@ -325,7 +361,11 @@ describe('QueueProcessor', () => { chatIdentity: '/c/another-chat' }); - expect(store.findById(item.id)).toMatchObject({ + expect(store.getItems()).toEqual([]); + const previousQueue = JSON.parse( + storage.getItem('chatgpt_prompt_queue_v2') + ).queues[getQueueScope('/c/test-chat')]; + expect(previousQueue.find(entry => entry.id === item.id)).toMatchObject({ status: 'error' }); expect(adapter.cancelPendingOperations) @@ -338,19 +378,31 @@ describe('QueueProcessor', () => { }); await attempt; - expect(store.findById(item.id)).not.toBeNull(); + expect(store.findById(item.id)).toBeNull(); expect(processor.getState()).toBe(QUEUE_STATE.IDLE); }); it('keeps confirming when a queued send creates a new chat', async () => { let confirmSend; - const adapter = createFakeChatGptAdapter(); + const adapter = createFakeChatGptAdapter({ + chatIdentity: '/' + }); + const tabLock = { + acquire: vi.fn(async () => true), + verifyOwnership: vi.fn(() => true), + setScope: vi.fn(async () => true) + }; adapter.waitForSendConfirmation.mockImplementationOnce( () => new Promise(resolve => { confirmSend = resolve; }) ); - const { processor, store } = createHarness({ adapter }); + const { processor, store } = createHarness({ adapter, tabLock }); + processor.handleLeadershipChange({ + isLeader: true, + ownerId: 'this-tab' + }); + await settleMicrotasks(); const item = store.add('create a chat'); await processor.processQueue(); @@ -364,13 +416,30 @@ describe('QueueProcessor', () => { previousChatIdentity: '/', chatIdentity: '/c/new-chat' }); + processor.handleLeadershipChange({ + isLeader: false, + ownerId: null, + reason: 'scope_changed' + }); + processor.handleLeadershipChange({ + isLeader: false, + ownerId: 'other-tab', + reason: 'lease_changed' + }); + expect(store.getScope()).toBe('chat:new-chat'); expect(store.findById(item.id)).toMatchObject({ status: 'sending' }); + expect(tabLock.setScope).toHaveBeenCalledWith('chat:new-chat'); expect(adapter.cancelPendingOperations) .not.toHaveBeenCalled(); + processor.handleLeadershipChange({ + isLeader: true, + ownerId: 'this-tab' + }); + confirmSend({ confirmed: true, reason: null, @@ -384,6 +453,161 @@ describe('QueueProcessor', () => { ); }); + it('switches the visible queue immediately on manual chat navigation', () => { + const storage = createMemoryStorage(); + const adapter = createFakeChatGptAdapter({ + chatIdentity: '/c/alpha' + }); + const { processor, store } = createHarness({ storage, adapter }); + + store.add('alpha prompt'); + store.setScope('chat:beta'); + store.add('beta prompt'); + store.setScope('chat:alpha'); + + processor.handleChatActivity({ + type: 'chat_changed', + chatIdentity: '/c/beta' + }); + + expect(store.getScope()).toBe('chat:beta'); + expect(store.getItems().map(item => item.text)).toEqual([ + 'beta prompt' + ]); + }); + + it('keeps edited items out of processing and rejects empty text', async () => { + const { adapter, processor, store } = createHarness(); + const item = store.add('original text'); + + expect(processor.beginPromptEdit(item.id)).toBe(true); + expect(item.status).toBe('editing'); + + await processor.processQueue(); + expect(adapter.findComposer).not.toHaveBeenCalled(); + expect(adapter.button.click).not.toHaveBeenCalled(); + + expect(processor.savePromptEdit(item.id, ' ')).toBe(false); + expect(item).toMatchObject({ + text: 'original text', + status: 'editing' + }); + + expect(processor.savePromptEdit(item.id, ' edited text ')).toBe(true); + expect(item).toMatchObject({ + text: 'edited text', + status: 'pending' + }); + }); + + it('cancels retry delay when its item enters editing', async () => { + const adapter = createFakeChatGptAdapter({ + confirmations: [ + { + confirmed: false, + processingStarted: false + } + ] + }); + const { processor, store } = createHarness({ adapter }); + const item = store.add('edit before retry'); + + await moveFromIdleToAttempt(processor); + expect(processor.getState()).toBe(QUEUE_STATE.RETRY_DELAY); + expect(item.status).toBe('pending'); + + expect(processor.beginPromptEdit(item.id)).toBe(true); + expect(item.status).toBe('editing'); + expect(processor.getState()).toBe(QUEUE_STATE.IDLE); + + await vi.advanceTimersByTimeAsync(10000); + await processor.processQueue(); + expect(adapter.button.click).toHaveBeenCalledOnce(); + }); + + it('restores an error after cancel and retries only on request', () => { + const { processor, store } = createHarness(); + const item = store.add('failed prompt'); + item.status = 'error'; + item.attempts = 3; + store.save(); + + expect(processor.beginPromptEdit(item.id)).toBe(true); + expect(item.status).toBe('editing_error'); + expect(processor.cancelPromptEdit(item.id)).toBe(true); + expect(item.status).toBe('error'); + expect(item.attempts).toBe(3); + + expect(processor.retryPrompt(item.id)).toBe(true); + expect(item).toMatchObject({ + status: 'pending', + attempts: 0 + }); + }); + + it('cancels removal while preparing before Send is clicked', async () => { + let resolveSendButton; + const adapter = createFakeChatGptAdapter(); + adapter.waitForSendButton.mockImplementationOnce( + () => new Promise(resolve => { + resolveSendButton = resolve; + }) + ); + const { processor, store } = createHarness({ adapter }); + const item = store.add('cancel before click'); + + await processor.processQueue(); + await vi.advanceTimersByTimeAsync(SEND_DELAY); + const attempt = processor.processQueue(); + await vi.advanceTimersByTimeAsync(PREPARE_DELAY); + await settleMicrotasks(); + + expect(store.findById(item.id)?.status).toBe('preparing'); + expect(processor.removePrompt(item.id)).toBe(true); + expect(store.findById(item.id)).toBeNull(); + expect(adapter.composer.text).toBe(''); + + resolveSendButton({ + button: adapter.button, + reason: null + }); + await attempt; + + expect(adapter.button.click).not.toHaveBeenCalled(); + }); + + it('refuses to remove an item after Send was clicked', async () => { + let confirmSend; + const adapter = createFakeChatGptAdapter(); + adapter.waitForSendConfirmation.mockImplementationOnce( + () => new Promise(resolve => { + confirmSend = resolve; + }) + ); + const { onStatus, processor, store } = createHarness({ adapter }); + const item = store.add('already handed to ChatGPT'); + + await processor.processQueue(); + await vi.advanceTimersByTimeAsync(SEND_DELAY); + const attempt = processor.processQueue(); + await vi.advanceTimersByTimeAsync(PREPARE_DELAY); + await settleMicrotasks(); + + expect(store.findById(item.id)?.status).toBe('sending'); + expect(processor.removePrompt(item.id)).toBe(false); + expect(store.findById(item.id)).not.toBeNull(); + expect(onStatus).toHaveBeenCalledWith( + 'Сообщение уже передано в ChatGPT и не может быть отменено' + ); + + confirmSend({ + confirmed: true, + processingStarted: true + }); + await attempt; + expect(store.findById(item.id)).toBeNull(); + }); + it('does not process without the tab lease', async () => { const tabLock = { verifyOwnership: vi.fn(() => false), diff --git a/test/queue-store.test.js b/test/queue-store.test.js index be9b456..b84b20a 100644 --- a/test/queue-store.test.js +++ b/test/queue-store.test.js @@ -8,7 +8,8 @@ import { } from 'vitest'; import { createQueueStore, - STORAGE_KEY + STORAGE_KEY, + STORAGE_VERSION } from '../src/queue-store.js'; import { createMemoryStorage } from './helpers/memory-storage.js'; @@ -55,9 +56,56 @@ describe('QueueStore', () => { expect(store.remove(first.id)).toBe(true); expect(store.remove('missing')).toBe(false); - expect(JSON.parse(storage.getItem(STORAGE_KEY))).toEqual([ - second + expect( + JSON.parse(storage.getItem(STORAGE_KEY)) + .queues.temporary + ).toEqual([second]); + }); + + it('moves an item before or after another item and persists the order', () => { + const storage = createMemoryStorage(); + const store = createQueueStore(storage); + const first = store.add('first'); + const second = store.add('second'); + const third = store.add('third'); + + expect( + store.moveRelative(third.id, first.id, 'before') + ).toBe(true); + expect(store.getItems().map(item => item.id)).toEqual([ + third.id, + first.id, + second.id ]); + + expect( + store.moveRelative(third.id, second.id, 'after') + ).toBe(true); + expect(store.getItems().map(item => item.id)).toEqual([ + first.id, + second.id, + third.id + ]); + expect( + store.moveRelative(third.id, third.id, 'before') + ).toBe(false); + expect( + JSON.parse(storage.getItem(STORAGE_KEY)) + .queues.temporary + ).toEqual(store.getItems()); + }); + + it('selects only pending items for processing', () => { + const store = createQueueStore(createMemoryStorage()); + const editing = store.add('being edited'); + const failed = store.add('failed'); + const pending = store.add('ready'); + + editing.status = 'editing'; + failed.status = 'error'; + store.save(); + + expect(store.findNextProcessable()).toBe(pending); }); it('restores a persisted queue in a new store instance', () => { @@ -119,7 +167,77 @@ describe('QueueStore', () => { attachments: [] }); expect(items[2].id).not.toBe('kept-id'); - expect(JSON.parse(storage.getItem(STORAGE_KEY))).toEqual(items); + expect(JSON.parse(storage.getItem(STORAGE_KEY))).toEqual({ + version: STORAGE_VERSION, + queues: { + temporary: items + }, + revisions: {} + }); + }); + + it('isolates chat scopes without stale tabs overwriting each other', () => { + const storage = createMemoryStorage(); + const alpha = createQueueStore(storage, null, 'chat:alpha'); + const beta = createQueueStore(storage, null, 'chat:beta'); + + alpha.add('alpha one'); + beta.add('beta one'); + alpha.add('alpha two'); + + expect(alpha.getItems().map(item => item.text)).toEqual([ + 'alpha one', + 'alpha two' + ]); + expect(beta.getItems().map(item => item.text)).toEqual([ + 'beta one' + ]); + + beta.setScope('chat:alpha'); + expect(beta.getItems().map(item => item.text)).toEqual([ + 'alpha one', + 'alpha two' + ]); + }); + + it('migrates the temporary queue into a created chat without loss', () => { + const storage = createMemoryStorage(); + const temporary = createQueueStore(storage); + const active = temporary.add('active first send'); + temporary.add('queued after active'); + active.status = 'sending'; + temporary.save(); + + const target = createQueueStore(storage, null, 'chat:created'); + target.add('already in target'); + + expect(temporary.migrateToScope('chat:created')).toBe(true); + expect(temporary.getScope()).toBe('chat:created'); + expect(temporary.findById(active.id)).toBe(active); + expect(temporary.getItems().map(item => item.text)).toEqual([ + 'active first send', + 'queued after active', + 'already in target' + ]); + + const persisted = JSON.parse(storage.getItem(STORAGE_KEY)); + expect(persisted.queues.temporary).toBeUndefined(); + expect(persisted.queues['chat:created']).toHaveLength(3); + }); + + it('assigns a legacy global queue to the chat open during migration', () => { + const storage = createMemoryStorage({ + [STORAGE_KEY]: JSON.stringify(['legacy in current chat']) + }); + const store = createQueueStore(storage, null, 'chat:current'); + + expect(store.getItems()).toMatchObject([ + { text: 'legacy in current chat' } + ]); + expect( + JSON.parse(storage.getItem(STORAGE_KEY)) + .queues['chat:current'] + ).toHaveLength(1); }); it('starts with an empty queue when persisted JSON is unusable', () => { @@ -166,4 +284,55 @@ describe('QueueStore', () => { second.stopSync(); expect(listeners.has('storage')).toBe(false); }); + + it('repairs a newer scope after a concurrent write drops it', () => { + const storage = createMemoryStorage(); + const listeners = new Map(); + const eventTarget = { + addEventListener: (type, listener) => { + listeners.set(type, listener); + }, + removeEventListener: type => { + listeners.delete(type); + } + }; + const alpha = createQueueStore( + storage, + eventTarget, + 'chat:alpha' + ); + + alpha.startSync(); + alpha.add('alpha must survive'); + + storage.setItem(STORAGE_KEY, JSON.stringify({ + version: STORAGE_VERSION, + queues: { + 'chat:beta': [ + { + id: 'beta-id', + text: 'beta concurrent', + status: 'pending', + createdAt: Date.now(), + attempts: 0, + attachments: [] + } + ] + }, + revisions: { + 'chat:beta': 1 + } + })); + listeners.get('storage')({ key: STORAGE_KEY }); + + const repaired = JSON.parse(storage.getItem(STORAGE_KEY)); + + expect(repaired.queues['chat:alpha']) + .toMatchObject([{ text: 'alpha must survive' }]); + expect(repaired.queues['chat:beta']) + .toMatchObject([{ text: 'beta concurrent' }]); + expect(repaired.revisions['chat:alpha']).toBe(1); + expect(alpha.getItems()) + .toMatchObject([{ text: 'alpha must survive' }]); + }); }); diff --git a/test/tab-lock.test.js b/test/tab-lock.test.js index 90fdb5e..02d2d53 100644 --- a/test/tab-lock.test.js +++ b/test/tab-lock.test.js @@ -6,12 +6,16 @@ import { it, vi } from 'vitest'; -import { createTabLock } from '../src/tab-lock.js'; +import { + createTabLock, + getScopedTabLockKey +} from '../src/tab-lock.js'; import { createMemoryStorage } from './helpers/memory-storage.js'; -function createLock(storage) { +function createLock(storage, scope = 'temporary') { return createTabLock({ storage, + scope, eventTarget: null, channelFactory: () => null, random: () => 0, @@ -71,4 +75,43 @@ describe('TabLock', () => { first.stop(); second.stop(); }); + + it('allows different chats to hold independent leases', async () => { + const storage = createMemoryStorage(); + const alpha = createLock(storage, 'chat:alpha'); + const beta = createLock(storage, 'chat:beta'); + + alpha.start(); + beta.start(); + await vi.advanceTimersByTimeAsync(120); + + expect(alpha.isLeader()).toBe(true); + expect(beta.isLeader()).toBe(true); + expect(storage.getItem(getScopedTabLockKey('chat:alpha'))) + .not.toBeNull(); + expect(storage.getItem(getScopedTabLockKey('chat:beta'))) + .not.toBeNull(); + + alpha.stop(); + beta.stop(); + }); + + it('releases the old scope before acquiring the new chat lease', async () => { + const storage = createMemoryStorage(); + const lock = createLock(storage); + + lock.start(); + await vi.advanceTimersByTimeAsync(120); + expect(lock.isLeader()).toBe(true); + + const switched = lock.setScope('chat:created'); + expect(storage.getItem(getScopedTabLockKey('temporary'))) + .toBeNull(); + await vi.advanceTimersByTimeAsync(120); + + expect(await switched).toBe(true); + expect(lock.getScope()).toBe('chat:created'); + expect(lock.verifyOwnership()).toBe(true); + lock.stop(); + }); });