(function (root, factory) { const createSearchPreview = factory(); if (typeof module === 'object' && module.exports) module.exports = createSearchPreview; if (root) { root.createSearchPreview = createSearchPreview; root.safeSearchUrl = value => { try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; } catch (_) { return ''; } }; root.searchPreviewUrl = (state, defaultScope, location) => { const { query, preview, scope = defaultScope } = state; const params = new URLSearchParams({ search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number, search_kind:scope.kind, search_state:scope.state, }); if (scope.repository) params.set('search_repository', scope.repository); return new URL('?' + params, location.origin + location.pathname).href; }; root.searchPreviewPath = item => { const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/preview?kind=' + encodeURIComponent(item.kind); }; root.searchPreviewConversationPath = (item, page) => { const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); const query = new URLSearchParams({ kind:item.kind, limit:'20' }); if (page) query.set('page', String(page)); return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/preview/conversation?' + query.toString(); }; root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') + '/comments?kind=' + encodeURIComponent(item.kind); root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({ storage, createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(), postReply:(item,body,operationId) => fetchJson(root.searchPreviewReplyPath(item), { method:'POST', headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':operationId}, body:JSON.stringify({body}), }), }); root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => { const comments = document.querySelector('#search-preview-comments'); const status = document.querySelector('#search-preview-conversation-status'); const retry = document.querySelector('#retry-search-preview-conversation'); const older = document.querySelector('#load-older-search-preview-comments'); if (!conversation) { comments.textContent = status.textContent = ''; retry.hidden = older.hidden = true; return; } comments.innerHTML = (conversation.comments || []).map(comment => '
' + '
' + escapeHtml(comment.author || 'Unknown author') + (comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') + '
' + '
' + renderMarkdown(comment.body || '') + '
' ).join(''); retry.hidden = conversation.status !== 'error'; older.hidden = conversation.status !== 'ready' || !conversation.olderPage; older.disabled = conversation.status === 'loading'; if (conversation.status === 'loading') status.textContent = conversation.comments?.length ? 'Loading older messages…' : 'Loading current conversation…'; else if (conversation.status === 'error') status.textContent = 'Conversation unavailable. Preview and planning actions still work.'; else if (!conversation.comments?.length) status.textContent = 'No conversation yet.'; else status.textContent = conversation.comments.length + (conversation.comments.length === 1 ? ' message.' : ' messages.'); }; root.renderSearchPreviewReply = (state, detail, preview, document) => { const section = document.querySelector('.search-preview-reply'); const input = document.querySelector('#search-preview-reply'); const status = document.querySelector('#search-preview-reply-status'); const buttons = ['#send-search-preview-reply', '#send-search-preview-reply-next'] .map(selector => document.querySelector(selector)); section.hidden = detail?.commentable !== true; status.textContent = ''; if (section.hidden) { buttons.forEach(button => { button.disabled = true; }); return; } input.value = preview.replyDraft(); const replying = state.status === 'replying'; buttons.forEach(button => { button.disabled = replying || (!input.value.trim() && !preview.hasReplyAttachments?.()); }); if (replying) status.textContent = 'Sending reply…'; else if (state.status === 'replied') status.textContent = 'Reply posted.'; else if (state.status === 'reply-error') status.textContent = state.error?.message || 'Reply failed. Your draft is safe; retry when ready.'; }; } })(typeof globalThis !== 'undefined' ? globalThis : this, function () { return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) { if (Array.isArray(session)) { getSession = session[0]; loadMore = () => session[1].loadMore(); onNavigate = session[2]; navigationRoot = document; } let generation = 0; let current = null; let mutationRequest = null; let shareRequest = null; let moveRequest = null; let replyRequest = null; let conversation = null; function sameItem(left, right) { return left && right && left.kind === right.kind && left.repository === right.repository && Number(left.number) === Number(right.number); } function replyKey(item, suffix) { return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number] .map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix; } function stored(key) { try { return storage?.getItem(key) || ''; } catch (_) { return ''; } } function save(key, value) { try { if (value) storage?.setItem(key, value); else storage?.removeItem(key); } catch (_) { /* Volatile drafts still remain in the textarea. */ } } function navigation(item) { const session = typeof getSession === 'function' ? getSession() : null; const items = Array.isArray(session?.items) ? session.items : []; const index = items.findIndex(candidate => sameItem(candidate, item)); if (index < 0) return null; return { position:index + 1, total:items.length, hasPrevious:index > 0, hasNext:index < items.length - 1 || session.more === true, }; } function publish(state) { const position = navigation(state.item || current); if (navigationRoot) { const bar = navigationRoot.querySelector('.search-preview-navigation'); bar.hidden = !position; if (position) { navigationRoot.querySelector('#search-preview-position').textContent = position.position + ' of ' + position.total; navigationRoot.querySelector('#previous-search-result').disabled = !position.hasPrevious; navigationRoot.querySelector('#next-search-result').disabled = !position.hasNext; } } onState(position ? { ...state, navigation:position } : state); if (state.complete && navigationRoot) { navigationRoot.querySelector('#search-preview-status').textContent = 'Search pass complete.'; } } function run(action, pending, success, detail) { if (mutationRequest) return mutationRequest; publish({ status: pending, item: current, detail }); mutationRequest = mutate(detail, action).then(result => { publish({ status: success, item: current, detail, result }); return result; }).catch(error => { publish({ status: 'ready', item: current, detail, error }); throw error; }).finally(() => { mutationRequest = null; }); return mutationRequest; } function loadConversation(detail, requestGeneration, page) { if (typeof fetchConversation !== 'function') return Promise.resolve(null); const previousComments = page && Array.isArray(conversation?.comments) ? conversation.comments : []; conversation = { status:'loading', comments:previousComments, olderPage:page ?? null }; publish({ status:'ready', item:current, detail, conversation }); return fetchConversation(detail, page).then(result => { if (requestGeneration !== generation) return result; const incoming = Array.isArray(result?.comments) ? result.comments : []; const liveComments = Array.isArray(conversation?.comments) ? conversation.comments : previousComments; const comments = [...incoming, ...liveComments].filter((comment, index, all) => all.findIndex(candidate => candidate?.id === comment?.id) === index); conversation = { status:'ready', comments, olderPage:result?.older_page ?? null, }; publish({ status:'ready', item:current, detail:current, conversation }); return result; }).catch(error => { if (requestGeneration === generation) { conversation = { status:'error', comments:previousComments, olderPage:page ?? conversation?.olderPage ?? null, error, }; publish({ status:'ready', item:current, detail:current, conversation }); } return null; }); } const api = { hasReplyAttachments() { return Boolean(hasAttachments?.()); }, open(item) { if (current && !sameItem(current, item) && hasAttachments?.()) clearAttachments?.(); generation += 1; const requestGeneration = generation; current = { ...item }; conversation = null; publish({ status: 'loading', item: current }); return fetchJson(current).then(detail => { if (requestGeneration === generation) { current = { ...current, ...detail }; if (typeof fetchConversation === 'function') { loadConversation(current, requestGeneration); } else { publish({ status: 'ready', item: current, detail }); } } return detail; }).catch(error => { if (requestGeneration === generation) { publish({ status: 'error', item: current, error }); } throw error; }); }, close() { clearAttachments?.(); generation += 1; current = null; onState({ status: 'closed' }); }, previous() { const session = typeof getSession === 'function' ? getSession() : null; const items = Array.isArray(session?.items) ? session.items : []; const index = items.findIndex(item => sameItem(item, current)); if (index <= 0) return Promise.resolve(current); const item = items[index - 1]; if (typeof onNavigate === 'function') onNavigate(item); return api.open(item); }, next() { if (moveRequest) return moveRequest; moveRequest = (async () => { let session = typeof getSession === 'function' ? getSession() : null; let items = Array.isArray(session?.items) ? session.items : []; let index = items.findIndex(item => sameItem(item, current)); if (index < 0) return current; if (index === items.length - 1 && session?.more === true && typeof loadMore === 'function') { await loadMore(); session = getSession(); items = Array.isArray(session?.items) ? session.items : []; index = items.findIndex(item => sameItem(item, current)); } if (index >= 0 && index < items.length - 1) { const item = items[index + 1]; if (typeof onNavigate === 'function') onNavigate(item); return api.open(item); } publish({ status:'ready', item:current, detail:current, complete:true }); return current; })().finally(() => { moveRequest = null; }); return moveRequest; }, retryConversation() { if (!current) return Promise.resolve(null); return loadConversation(current, generation); }, loadOlderConversation() { if (!current || !conversation?.olderPage) return Promise.resolve(null); return loadConversation(current, generation, conversation.olderPage); }, saveReplyDraft(body) { if (!current) return ''; const key = replyKey(current, 'draft'); const next = String(body || ''); if (stored(key) !== next) save(replyKey(current, 'operation'), ''); save(key, next); return next; }, replyDraft() { return current ? stored(replyKey(current, 'draft')) : ''; }, reply({ advance = false } = {}) { if (replyRequest) return replyRequest; if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.')); const body = api.replyDraft().trim(); if (!body && !hasAttachments?.()) return Promise.reject(new Error('Write a reply or add a photo first.')); const item = { ...current }; const operationKey = replyKey(item, 'operation'); let operationId = stored(operationKey); if (!operationId) { operationId = String(createOperationId?.() || Date.now()).slice(0, 128); save(operationKey, operationId); } publish({ status:'replying', item:current, detail:current, conversation }); replyRequest = Promise.resolve( typeof prepareReply === 'function' ? prepareReply(item, body) : body ).then(preparedBody => { if (!String(preparedBody || '').trim()) throw new Error('Write a reply or add a photo first.'); return postReply(item, preparedBody, operationId); }).then(async comment => { await afterReply?.(item); const comments = [...(conversation?.comments || [])]; if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment); conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null }; save(replyKey(item, 'draft'), ''); save(operationKey, ''); clearAttachments?.(); publish({ status:'replied', item:current, detail:current, conversation, result:comment }); return advance ? api.next().then(() => comment) : comment; }).catch(error => { publish({ status:'reply-error', item:current, detail:current, conversation, error }); throw error; }).finally(() => { replyRequest = null; }); return replyRequest; }, share(url) { if (shareRequest) return shareRequest; if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.')); const detail = current; publish({ status:'sharing', item:current, detail }); shareRequest = share(url).then(result => { publish({ status:result, item:current, detail }); return result; }).catch(error => { publish({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error }); throw error; }).finally(() => { shareRequest = null; }); return shareRequest; }, claim(detail) { return run('claim', 'claiming', 'claimed', detail); }, reopen(detail) { return run('reopen', 'reopening', 'reopened', detail); }, }; if (navigationRoot) { navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {})); navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {})); navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation()); navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation()); const reply = navigationRoot.querySelector('#search-preview-reply'); reply?.addEventListener('input', event => { api.saveReplyDraft(event.target.value); const disabled = !event.target.value.trim(); navigationRoot.querySelector('#send-search-preview-reply').disabled = disabled; navigationRoot.querySelector('#send-search-preview-reply-next').disabled = disabled; }); reply?.addEventListener('focus', event => event.target.scrollIntoView({ block:'center', behavior:'smooth' })); navigationRoot.querySelector('#send-search-preview-reply')?.addEventListener('click', () => api.reply().catch(() => {})); navigationRoot.querySelector('#send-search-preview-reply-next')?.addEventListener('click', () => api.reply({ advance:true }).catch(() => {})); } return api; }; });