Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
607 changes: 607 additions & 0 deletions apps/desktop/src/app/chat/composer/hooks/use-composer-queue.test.tsx

Large diffs are not rendered by default.

372 changes: 327 additions & 45 deletions apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1508,6 +1508,9 @@ export const en: Translations = {
queueDelete: 'Delete',
queueStuckTitle: 'Queued message not sent',
queueStuckBody: 'A queued turn kept failing to send. It is still in the queue — try sending it again.',
queueBusyElsewhereTitle: 'Queued message not sent yet',
queueBusyElsewhereBody:
'The queue is busy sending another message. Yours is still queued — try again in a moment.',
previewUnavailable: 'Preview unavailable',
previewLabel: label => `Preview ${label}`,
couldNotPreview: label => `Could not preview ${label}`,
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,9 @@ export const ja = defineLocale({
queueStuckTitle: 'キュー内のメッセージを送信できません',
queueStuckBody:
'キューに入れたターンの送信が繰り返し失敗しました。まだキューに残っています。もう一度送信してください。',
queueBusyElsewhereTitle: 'キュー内のメッセージはまだ送信されていません',
queueBusyElsewhereBody:
'キューは別のメッセージを送信中です。メッセージはまだキューに残っています。しばらくしてからもう一度お試しください。',
previewUnavailable: 'プレビューは利用できません',
previewLabel: label => `${label} のプレビュー`,
couldNotPreview: label => `${label} をプレビューできませんでした`,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,8 @@ export interface Translations {
queueDelete: string
queueStuckTitle: string
queueStuckBody: string
queueBusyElsewhereTitle: string
queueBusyElsewhereBody: string
previewUnavailable: string
previewLabel: (label: string) => string
couldNotPreview: (label: string) => string
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1564,6 +1564,8 @@ export const zhHant = defineLocale({
queueDelete: '刪除',
queueStuckTitle: '佇列訊息未送出',
queueStuckBody: '佇列中的對話多次傳送失敗。它仍在佇列中,請重試傳送。',
queueBusyElsewhereTitle: '佇列訊息尚未送出',
queueBusyElsewhereBody: '佇列正在傳送另一則訊息。您的訊息仍在佇列中,請稍後再試。',
previewUnavailable: '預覽不可用',
previewLabel: label => `預覽 ${label}`,
couldNotPreview: label => `無法預覽 ${label}`,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1687,6 +1687,8 @@ export const zh: Translations = {
queueDelete: '删除',
queueStuckTitle: '排队消息未发送',
queueStuckBody: '排队的对话多次发送失败。它仍在队列中,请重试发送。',
queueBusyElsewhereTitle: '排队消息尚未发送',
queueBusyElsewhereBody: '队列正在发送另一条消息。您的消息仍在队列中,请稍后重试。',
previewUnavailable: '预览不可用',
previewLabel: label => `预览 ${label}`,
couldNotPreview: label => `无法预览 ${label}`,
Expand Down
148 changes: 148 additions & 0 deletions apps/desktop/src/store/composer-queue-test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import {
$queuedPromptsBySession,
clearQueuedPrompts,
enqueueQueuedPrompt,
QUEUE_STORAGE_KEY,
QUEUE_TOMBSTONES_STORAGE_KEY
} from './composer-queue'
import type { QueuedPromptEntry } from './composer-queue'

/**
* Shared helpers for the composer-queue suites (store + hook). The fake lock
* manager encodes real Web Locks semantics — exclusivity, FIFO waiting,
* `ifAvailable` null grants, abort-signal rejection, release-on-settle, and a
* `query()` snapshot of held locks — so both suites model cross-window
* contention against the SAME behavior.
*/

interface FakeLockOptions {
ifAvailable?: boolean
signal?: AbortSignal
}

type FakeLockCallback = (lock: null | { name: string }) => Promise<unknown> | unknown

export function installFakeLocks({ failWaits = false }: { failWaits?: boolean } = {}) {
// name → FIFO of waiters. A key existing (even with an empty array) means
// the lock is currently held.
const queues = new Map<string, Array<() => void>>()

const release = (name: string) => {
const waiters = queues.get(name)
const next = waiters?.shift()

if (next) {
next() // hand over; the map entry stays = still held
} else {
queues.delete(name)
}
}

const request = async (name: string, options: FakeLockOptions = {}, callback: FakeLockCallback) => {
if (queues.has(name)) {
if (options.ifAvailable) {
return callback(null)
}

// failWaits simulates a wait that outlives its AbortSignal budget
// without spending real wall-clock time on it. Only signal-carrying
// requests can time out — unbounded waits (no signal) keep waiting,
// exactly like the real API.
if (failWaits && options.signal) {
throw new DOMException('Fake lock wait timed out', 'TimeoutError')
}

await new Promise<void>((resolve, reject) => {
const waiter = () => resolve()
const signal = options.signal

if (signal) {
if (signal.aborted) {
reject(signal.reason instanceof DOMException ? signal.reason : new DOMException('Aborted', 'AbortError'))

return
}

signal.addEventListener(
'abort',
() => {
const waiters = queues.get(name)
const index = waiters ? waiters.indexOf(waiter) : -1

if (waiters && index >= 0) {
waiters.splice(index, 1)
}

reject(signal.reason instanceof DOMException ? signal.reason : new DOMException('Aborted', 'AbortError'))
},
{ once: true }
)
}

queues.get(name)!.push(waiter)
})
} else {
queues.set(name, [])
}

try {
return await callback({ name })
} finally {
release(name)
}
}

const query = async () => ({
held: [...queues.keys()].map(name => ({ name })),
pending: [] as { name: string }[]
})

Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { query, request } })

return () => {
delete (window.navigator as { locks?: unknown }).locks
}
}

/**
* Reset every storage surface the queue store uses, plus the atom — and heal
* the store's module state through its public API: one successful save clears
* the persist-failure flag and one successful tombstone write flushes the
* in-memory tombstone overlay, both of which would otherwise leak across
* tests (they are module-level, not storage-level).
*/
export function resetQueueStorage() {
enqueueQueuedPrompt('__queue-test-reset__', { attachments: [], text: 'reset' })
clearQueuedPrompts('__queue-test-reset__')
window.localStorage.removeItem(QUEUE_STORAGE_KEY)
window.localStorage.removeItem(QUEUE_TOMBSTONES_STORAGE_KEY)
$queuedPromptsBySession.set({})
}

export function remoteEntry(id: string, text: string): QueuedPromptEntry {
return { id, text, attachments: [], queuedAt: 1 }
}

/**
* Simulate another window writing the shared queue key. With `fireEvent` the
* `storage` event is dispatched too (it never fires in the writing window
* itself, so dispatching manually is exactly the other-window signal); without
* it, the write models the worst case — persisted but not yet synced.
*/
export function otherWindowWrites(state: Record<string, unknown>, { fireEvent = false } = {}) {
const value = JSON.stringify(state)
window.localStorage.setItem(QUEUE_STORAGE_KEY, value)

if (fireEvent) {
window.dispatchEvent(new StorageEvent('storage', { key: QUEUE_STORAGE_KEY, newValue: value }))
}
}

export function persistedQueueTexts(sid: string): string[] {
const parsed = JSON.parse(window.localStorage.getItem(QUEUE_STORAGE_KEY) ?? '{}') as Record<
string,
{ text: string }[]
>

return (parsed[sid] ?? []).map(e => e.text)
}
Loading