diff --git a/README.md b/README.md index e344185..adb34ec 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,12 @@ issue-comment API. In issue, pull-request, and unread-update conversations, typi at least two characters after `@` offers repository-scoped teammate suggestions; touch or keyboard selection inserts the login without leaving the draft. Mention lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue -creation and assignment permission. Once a repository and meaningful title are selected, +creation and assignment permission. A fresh capture never silently targets the first repository: +the operator must explicitly choose one, either from the paginated browser or through the bounded +authenticated repository search. Search results include only visible repository identities, stale +responses cannot replace a newer query, and search failure leaves the draft and browse fallback +intact. Saved drafts restore their exact repository even when it is outside the first page. Once a +repository and meaningful title are selected, the New issue sheet checks for similar open issues in that repository. Candidate links keep the draft intact; the first create attempt pauses until the operator reviews them or explicitly chooses **Create anyway**. This check is advisory and never blocks offline capture or capture diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index 64c6ca8..a1e17fc 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -24,6 +24,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp const sharedStorageKey = 'stackchain.issue-share.v1'; let pending = null; let duplicateRequest = 0; + let repositorySearchRequest = 0; let duplicateState = {status: 'idle', key: '', candidates: []}; let acknowledgedDuplicateKey = ''; const repositoryPageRequests = new Map(); @@ -159,6 +160,25 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp return request; } + async function searchRepositories(value) { + const query = String(value || '').trim().slice(0, 80); + const request = ++repositorySearchRequest; + if (query.length < 2) return {status: 'idle', items: []}; + try { + const payload = await fetchJson( + 'api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20' + ); + if (request !== repositorySearchRequest) return {status: 'stale', items: []}; + return { + status: 'ready', + items: Array.isArray(payload?.items) ? payload.items : [], + }; + } catch (error) { + if (request !== repositorySearchRequest) return {status: 'stale', items: []}; + return {status: 'failed', items: [], error}; + } + } + function duplicateKey(draft) { const repository = String(draft?.repository || '').trim(); const title = String(draft?.title || '').replace(/\s+/g, ' ').trim(); @@ -230,7 +250,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp } return { - saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates, + saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, + searchRepositories, findDuplicates, needsDuplicateAcknowledgement, acknowledgeDuplicates, submit, stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent, }; diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 49fa492..e622186 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -349,6 +349,12 @@ textarea { resize: vertical; min-height: 120px; } .create-issue-form { display:grid; gap:12px; } .create-issue-attachment { display:grid; gap:8px; min-width:0; } .create-issue-repository-more { min-height:44px; width:100%; } +.create-issue-repository-picker { min-width:0; display:grid; gap:8px; } +.create-issue-repository-picker input { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; } +#create-issue-repository-results { display:grid; max-height:min(36dvh,280px); overflow:auto; border:1px solid #2a496e; border-radius:10px; } +#create-issue-repository-results[hidden] { display:none; } +.create-issue-repository-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; } +.create-issue-repository-result:last-child { border-bottom:0; } .create-issue-form label { display:grid; gap:6px; } .create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; } .create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 9a65365..f68f4db 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -2558,6 +2558,7 @@ let issueCaptureRepositories = []; let nextIssueRepositoryPage = 2; let moreIssueRepositoriesAvailable = false; + let issueRepositorySearchTimer = null; function appendIssueRepositories(items) { const select = qs('#create-issue-repository'); @@ -2576,6 +2577,43 @@ if (selected) select.value = selected; } + function updateIssueCreateActions() { + const hasRepository = Boolean(qs('#create-issue-repository').value); + qs('#submit-new-issue').disabled = !hasRepository; + qs('#create-and-start-issue').disabled = !hasRepository || !createAndStart.available(); + } + + function renderIssueRepositoryResults(items) { + const results = qs('#create-issue-repository-results'); + results.replaceChildren(); + (Array.isArray(items) ? items : []).forEach(item => { + const repository = String(item?.full_name || '').trim(); + if (!repository) return; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'create-issue-repository-result'; + button.setAttribute('role', 'option'); + button.textContent = repository; + button.addEventListener('click', () => selectIssueCaptureRepository(repository)); + results.appendChild(button); + }); + results.hidden = !results.childElementCount; + } + + function selectIssueCaptureRepository(repository) { + appendIssueRepositories([{full_name: repository}]); + qs('#create-issue-repository').value = repository; + qs('#create-issue-repository-search').value = repository; + qs('#create-issue-repository-results').hidden = true; + qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.'; + loadIssueLabels(repository); + loadIssueMilestones(repository); + saveIssueCaptureDraft(); + scheduleIssueDuplicateCheck(); + updateIssueCreateActions(); + qs('#create-issue-title').focus(); + } + let duplicateCheckTimer = null; function renderIssueDuplicates(state) { if (state.status === 'stale') return; @@ -2703,7 +2741,7 @@ if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) { issueCaptureRepositories.unshift(captureDraft.repository); } - qs('#create-issue-repository').innerHTML = issueCaptureRepositories.map(repository => + qs('#create-issue-repository').innerHTML = '' + issueCaptureRepositories.map(repository => '' ).join(''); if (nextIssueRepositoryPage === 2) { @@ -2711,6 +2749,9 @@ } qs('#load-more-issue-repositories').hidden = !moreIssueRepositoriesAvailable; if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository; + else qs('#create-issue-repository').value = ''; + qs('#create-issue-repository-search').value = captureDraft.repository || ''; + qs('#create-issue-repository-results').hidden = true; qs('#create-issue-title').value = captureDraft.title; qs('#create-issue-body').value = captureDraft.body; qs('#create-issue-due-date').value = captureDraft.dueDate || ''; @@ -2718,11 +2759,10 @@ loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId); scheduleIssueDuplicateCheck(); qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.'; - qs('#submit-new-issue').disabled = !issueCaptureRepositories.length; - qs('#create-and-start-issue').disabled = !issueCaptureRepositories.length || !createAndStart.available(); + updateIssueCreateActions(); qs('#create-issue-sheet').classList.add('open'); creatingIssue = true; - qs('#create-issue-title').focus(); + (captureDraft.repository ? qs('#create-issue-title') : qs('#create-issue-repository-search')).focus(); } let suppressCreateDraftOnHistoryClose = false; @@ -3564,8 +3604,34 @@ qs('#create-issue-repository').addEventListener('change', event => { loadIssueLabels(event.target.value); loadIssueMilestones(event.target.value); + qs('#create-issue-repository-search').value = event.target.value; saveIssueCaptureDraft(); scheduleIssueDuplicateCheck(); + updateIssueCreateActions(); + }); + qs('#create-issue-repository-search').addEventListener('input', event => { + clearTimeout(issueRepositorySearchTimer); + const query = event.target.value.trim(); + const results = qs('#create-issue-repository-results'); + const status = qs('#create-issue-repository-status'); + if (query.length < 2) { + issueCapture.searchRepositories(query); + results.hidden = true; + status.textContent = query ? 'Enter at least 2 characters to search.' : 'Search or browse to choose a repository.'; + return; + } + status.textContent = 'Searching accessible repositories…'; + issueRepositorySearchTimer = setTimeout(async () => { + const state = await issueCapture.searchRepositories(query); + if (state.status === 'stale') return; + if (state.status === 'failed') { + results.hidden = true; + status.textContent = 'Repository search failed. Your draft is safe; retry or browse below.'; + return; + } + renderIssueRepositoryResults(state.items); + status.textContent = state.items.length ? 'Choose a matching repository.' : 'No accessible repositories match.'; + }, 250); }); qs('#load-more-issue-repositories').addEventListener('click', async event => { const button = event.currentTarget; diff --git a/frontend/index.html b/frontend/index.html index 84a0164..e3f6023 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -408,9 +408,15 @@