From d8d2dd69110efdc41ed790543609ed71cb1e4ccf Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Mon, 9 Nov 2026 14:15:48 +0800 Subject: [PATCH 1/3] fix: identify note previews by linked change numbers --- scripts/check-note-results.mjs | 32 ++++++++++++++++++++++------- src/web/locales/de.json | 5 ++++- src/web/locales/es.json | 5 ++++- src/web/locales/fr.json | 5 ++++- src/web/locales/it.json | 5 ++++- src/web/locales/ja.json | 5 ++++- src/web/locales/ko.json | 5 ++++- src/web/locales/pt.json | 5 ++++- src/web/locales/sv.json | 5 ++++- src/web/locales/zh-CN.json | 5 ++++- src/web/locales/zh-Hant.json | 5 ++++- src/web/note-results.tsx | 15 ++++++++++++-- src/web/review-note-connections.tsx | 30 +++++++++++++++++++++------ src/web/style.css | 6 ++++++ 14 files changed, 108 insertions(+), 25 deletions(-) diff --git a/scripts/check-note-results.mjs b/scripts/check-note-results.mjs index 3ff6a870..b28a1f04 100644 --- a/scripts/check-note-results.mjs +++ b/scripts/check-note-results.mjs @@ -31,9 +31,9 @@ const items = [ ]; const runs = [{ id: 'request-1', taskId: task.id, reviewRound: 1, status: 'completed' }, { id: 'request-2', taskId: task.id, reviewRound: 2, status: 'completed' }]; const unit = (id, line, old, revised) => ({ id, file: 'main.tex', kind: 'text', status: 'modified', old_start: line, old_end: line + 2, new_start: line, new_end: line + 2, old_text: old, new_text: revised }); -const units = { 1: [unit('c1', 10, 'The original technical sentence.', 'A shorter technical sentence.'), unit('c2', 20, 'A second original sentence.', 'Our contribution is now explicit.')], 2: [unit('c1', 11, 'A shorter technical sentence.', 'A shorter sentence preserving the technical term.')] }; +const units = { 1: [unit('c1', 10, 'The original technical sentence.', 'A shorter technical sentence.'), unit('c2', 20, 'A second original sentence.', 'Our contribution is now explicit.'), unit('c3', 12, 'The original technical sentence.', 'A clearer technical sentence.')], 2: [unit('c1', 11, 'A shorter technical sentence.', 'A shorter sentence preserving the technical term.')] }; const rounds = Object.fromEntries([1, 2].map(round => [round, { round, units: units[round], sections: [], loose: units[round].map(unit => ({ kind: 'change', units: [unit.id], before: [{ text: [{ text: unit.old_text }] }] })) }])); -const decisions = { 1: { c1: 'pending', c2: 'rejected' }, 2: { c1: 'pending' } }; +const decisions = { 1: { c1: 'pending', c2: 'rejected', c3: 'accepted' }, 2: { c1: 'pending' } }; const client = { get: async route => route.includes('/rounds/') ? rounds[Number(route.split('/').at(-1))] : { decisions: decisions[Number(new URL(route, origin).searchParams.get('round'))] } }; let failResults = true; const optionWrites = []; @@ -89,7 +89,7 @@ await page.route('**/review/**', async route => { const url = new URL(route.request().url()); if (url.pathname.includes('/rounds/')) return route.fulfill({ json: rounds[Number(url.pathname.split('/').at(-1))] }); const round = Number(url.searchParams.get('round')) || 2; - const html = `
Round ${round}
${units[round].map((unit, index) => `
Change ${index + 1}main.tex:${unit.old_start}–${unit.old_end}

${unit.old_text}

${unit.new_text}

`).join('')}
`; + const html = `
Round ${round}
${units[round].map((unit, index) => `
Change ${index + 1}main.tex:${unit.old_start}–${unit.old_end}

${unit.old_text}

${unit.new_text}

`).join('')}
`; return route.fulfill({ contentType: 'text/html', body: rewriteReviewHtml(html, '/review/paper') }); }); const frame = () => page.frameLocator('.review-frame'); @@ -117,7 +117,8 @@ try { await frame().locator('html[data-papilot-round="1"]').waitFor(); await frame().locator('.papilot-linked-change[data-stop="1"]').waitFor(); assert.equal(await frame().locator('.papilot-note-options button').count(), 0, 'Historical changes do not advertise unavailable alternatives'); - assert.equal(await controls().locator('.review-note-position').textContent(), '2 / 3'); + assert.equal(await controls().locator('.review-note-position').textContent(), 'Note 2 / 3'); + await expect(controls().getByRole('button', { name: 'Show change 2', exact: true })).toHaveAttribute('aria-pressed', 'true'); assert.equal(await controls().getByRole('button', { name: 'Previous note', exact: true }).isEnabled(), true); assert.equal(await controls().getByRole('button', { name: 'Next note', exact: true }).isEnabled(), true); assert.match(await page.locator('.review-frame').getAttribute('src'), /round=1&history=1/); @@ -125,12 +126,29 @@ try { decisions[1].c1 = 'accepted'; await frame().locator('body').evaluate(element => element.ownerDocument.defaultView.dispatchEvent(new Event('latex-review:decided'))); await sent(0).click(); - assert.equal(await controls().locator('.review-note-position').textContent(), '1 / 3'); + assert.equal(await controls().locator('.review-note-position').textContent(), 'Note 1 / 3'); + await expect(controls().locator('.note-review-targets button')).toHaveText(['Change 1', 'Change 3']); + await controls().getByRole('button', { name: 'Show change 3', exact: true }).click(); + await expect(frame().locator('.papilot-linked-target')).toHaveAttribute('data-stop', '2'); + await expect(controls().getByRole('button', { name: 'Show change 3', exact: true })).toHaveAttribute('aria-pressed', 'true'); + assert.ok(await frame().locator('.papilot-linked-target').evaluate(element => { + const bounds = element.getBoundingClientRect(); + return bounds.top < window.innerHeight && bounds.bottom > 0; + }), 'A change link reveals its own change instead of the note’s first change'); + await controls().getByRole('button', { name: 'Show change 1', exact: true }).click(); + await expect(frame().locator('.papilot-linked-target')).toHaveAttribute('data-stop', '0'); + await frame().locator('.change[data-stop="2"] .papilot-note-links button').click(); + await expect(frame().locator('.papilot-linked-target')).toHaveAttribute('data-stop', '2'); + await expect(controls().getByRole('button', { name: 'Show change 3', exact: true })).toHaveAttribute('aria-pressed', 'true'); + const linkedHeader = await frame().locator('.papilot-linked-target .change-head').boundingBox(); + const linkedDrawer = await controls().boundingBox(); + assert.ok(linkedHeader.y + linkedHeader.height <= linkedDrawer.y, 'The linked header stays above the drawer'); assert.equal(await controls().getByRole('button', { name: 'Previous note', exact: true }).isEnabled(), false); const currentNote = page.locator('[data-note-result="request-2:0"]'); await currentNote.click(); await frame().locator('html[data-papilot-round="2"]').waitFor(); await frame().locator('.papilot-linked-change[data-stop="0"]').waitFor(); + await expect(controls().locator('.note-review-targets button')).toHaveText(['Change 1']); assert.doesNotMatch(await page.locator('.review-frame').getAttribute('src'), /[?&](history|round)=/); assert.deepEqual(await page.locator('[data-note-result]').allTextContents(), original, 'review operations never add content to original messages'); await controls().getByRole('region', { name: 'Complete PDF comparison', exact: true }).waitFor(); @@ -189,9 +207,9 @@ try { await page.getByRole('button', { name: 'Chat', exact: true }).click(); await sent(0).click(); await frame().locator('.papilot-linked-change[data-stop="0"]').waitFor(); - assert.equal(await controls().locator('.review-note-position').textContent(), '1 / 3'); + assert.equal(await controls().locator('.review-note-position').textContent(), 'Note 1 / 3'); assert.equal(await page.locator('.review-frame').isVisible(), true); - await frame().getByRole('button', { name: 'Show note 1: Shorten the opening.', exact: true }).click(); + await frame().getByRole('button', { name: 'Show note 1: Shorten the opening.', exact: true }).first().click(); assert.equal(await page.locator('.review-frame').isVisible(), true, 'PDF note badges keep the author in the review'); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), 390); const box = await controls().boundingBox(); diff --git a/src/web/locales/de.json b/src/web/locales/de.json index 886fcd40..5501ac76 100644 --- a/src/web/locales/de.json +++ b/src/web/locales/de.json @@ -1301,5 +1301,8 @@ "{min}–{max} alternatives": "{min}–{max} Alternativen", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "Ein Zielbereich ohne den Hauptvorschlag. Die KI kann weniger oder mehr sinnvolle Alternativen liefern. Mehr Alternativen benötigen mehr Vorbereitungszeit.", "Other options · {n}": "Weitere Optionen · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Zusammen mit den Änderungen vorbereitet. Die Anzahl ist ein Richtwert ohne den Hauptvorschlag. Gilt für künftige Anfragen." + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Zusammen mit den Änderungen vorbereitet. Die Anzahl ist ein Richtwert ohne den Hauptvorschlag. Gilt für künftige Anfragen.", + "Linked changes": "Zugehörige Änderungen", + "Change {n}": "Änderung {n}", + "Show change {n}": "Änderung {n} anzeigen" } diff --git a/src/web/locales/es.json b/src/web/locales/es.json index 1f84e01d..31072728 100644 --- a/src/web/locales/es.json +++ b/src/web/locales/es.json @@ -1301,5 +1301,8 @@ "{min}–{max} alternatives": "{min}–{max} alternativas", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "Un intervalo orientativo, sin contar la propuesta principal. La IA puede ofrecer menos o más alternativas útiles. Más alternativas requieren más tiempo de preparación.", "Other options · {n}": "Otras opciones · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Se preparan con los cambios. Cantidad aproximada, sin contar la propuesta principal. Se aplica a futuras solicitudes." + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Se preparan con los cambios. Cantidad aproximada, sin contar la propuesta principal. Se aplica a futuras solicitudes.", + "Linked changes": "Cambios vinculados", + "Change {n}": "Cambio {n}", + "Show change {n}": "Mostrar el cambio {n}" } diff --git a/src/web/locales/fr.json b/src/web/locales/fr.json index 6fb3824d..ccce7929 100644 --- a/src/web/locales/fr.json +++ b/src/web/locales/fr.json @@ -1301,5 +1301,8 @@ "{min}–{max} alternatives": "{min}–{max} variantes", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "Une fourchette indicative, hors proposition principale. L’IA peut proposer moins ou plus de variantes utiles. Plus de variantes demandent plus de temps de préparation.", "Other options · {n}": "Autres options · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Préparées avec les modifications. Nombre indicatif, hors proposition principale. S’applique aux prochaines demandes." + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Préparées avec les modifications. Nombre indicatif, hors proposition principale. S’applique aux prochaines demandes.", + "Linked changes": "Modifications associées", + "Change {n}": "Modification {n}", + "Show change {n}": "Afficher la modification {n}" } diff --git a/src/web/locales/it.json b/src/web/locales/it.json index f833077a..b22d9ded 100644 --- a/src/web/locales/it.json +++ b/src/web/locales/it.json @@ -1301,5 +1301,8 @@ "{min}–{max} alternatives": "{min}–{max} alternative", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "Un intervallo indicativo, esclusa la proposta principale. L’IA può fornire meno o più alternative utili. Più alternative richiedono più tempo di preparazione.", "Other options · {n}": "Altre opzioni · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Preparate insieme alle modifiche. Numero indicativo, esclusa la proposta principale. Si applica alle richieste future." + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Preparate insieme alle modifiche. Numero indicativo, esclusa la proposta principale. Si applica alle richieste future.", + "Linked changes": "Modifiche collegate", + "Change {n}": "Modifica {n}", + "Show change {n}": "Mostra la modifica {n}" } diff --git a/src/web/locales/ja.json b/src/web/locales/ja.json index d5e4949f..ebee9f81 100644 --- a/src/web/locales/ja.json +++ b/src/web/locales/ja.json @@ -1301,5 +1301,8 @@ "{min}–{max} alternatives": "{min}〜{max} 個の別案", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "主案を除いた目安です。AI が返す有用な案の数は前後することがあります。案が多いほど準備に時間がかかります。", "Other options · {n}": "別の案 · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "編集と同時に用意します。主案を除く数の目安です。今後のリクエストに適用されます。" + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "編集と同時に用意します。主案を除く数の目安です。今後のリクエストに適用されます。", + "Linked changes": "対応する変更", + "Change {n}": "変更 {n}", + "Show change {n}": "変更 {n} を表示" } diff --git a/src/web/locales/ko.json b/src/web/locales/ko.json index b977a4aa..7e51db80 100644 --- a/src/web/locales/ko.json +++ b/src/web/locales/ko.json @@ -1301,5 +1301,8 @@ "{min}–{max} alternatives": "대안 {min}–{max}개", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "기본 제안을 제외한 목표 범위입니다. AI가 유용한 대안을 더 적게 또는 많이 제시할 수 있습니다. 대안이 많을수록 준비 시간이 길어집니다.", "Other options · {n}": "다른 대안 · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "편집과 함께 준비합니다. 개수는 기본 제안을 제외한 대략적인 수이며, 이후 요청부터 적용됩니다." + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "편집과 함께 준비합니다. 개수는 기본 제안을 제외한 대략적인 수이며, 이후 요청부터 적용됩니다.", + "Linked changes": "연결된 변경 사항", + "Change {n}": "변경 {n}", + "Show change {n}": "변경 {n} 보기" } diff --git a/src/web/locales/pt.json b/src/web/locales/pt.json index 7d7fc24a..087b2126 100644 --- a/src/web/locales/pt.json +++ b/src/web/locales/pt.json @@ -1301,5 +1301,8 @@ "{min}–{max} alternatives": "{min}–{max} alternativas", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "Um intervalo indicativo, sem contar a proposta principal. A IA pode apresentar menos ou mais alternativas úteis. Mais alternativas exigem mais tempo de preparação.", "Other options · {n}": "Outras opções · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Preparadas com as alterações. Quantidade aproximada, sem contar a proposta principal. Aplica-se a pedidos futuros." + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Preparadas com as alterações. Quantidade aproximada, sem contar a proposta principal. Aplica-se a pedidos futuros.", + "Linked changes": "Alterações vinculadas", + "Change {n}": "Alteração {n}", + "Show change {n}": "Mostrar a alteração {n}" } diff --git a/src/web/locales/sv.json b/src/web/locales/sv.json index e5c14a49..38c131e5 100644 --- a/src/web/locales/sv.json +++ b/src/web/locales/sv.json @@ -1301,5 +1301,8 @@ "{min}–{max} alternatives": "{min}–{max} alternativ", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "Ett riktmärke som inte räknar med huvudförslaget. AI:n kan ge färre eller fler användbara alternativ. Fler alternativ tar längre tid att förbereda.", "Other options · {n}": "Andra alternativ · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Förbereds med ändringarna. Antalet är ungefärligt och räknar inte med huvudförslaget. Gäller framtida förfrågningar." + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Förbereds med ändringarna. Antalet är ungefärligt och räknar inte med huvudförslaget. Gäller framtida förfrågningar.", + "Linked changes": "Länkade ändringar", + "Change {n}": "Ändring {n}", + "Show change {n}": "Visa ändring {n}" } diff --git a/src/web/locales/zh-CN.json b/src/web/locales/zh-CN.json index 4ecceef1..9c860be2 100644 --- a/src/web/locales/zh-CN.json +++ b/src/web/locales/zh-CN.json @@ -125,5 +125,8 @@ "{min}–{max} alternatives": "{min}–{max} 个备选", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "不含首选方案。这是目标范围,AI 会按内容调整实际数量;更多备选需要更长的准备时间。", "Other options · {n}": "其他方案 · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "随修改一起准备;数量不含首选方案,仅作参考。只影响之后的请求。" + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "随修改一起准备;数量不含首选方案,仅作参考。只影响之后的请求。", + "Linked changes": "对应修改", + "Change {n}": "修改 {n}", + "Show change {n}": "定位修改 {n}" } diff --git a/src/web/locales/zh-Hant.json b/src/web/locales/zh-Hant.json index b3b0c421..2e8fc0a2 100644 --- a/src/web/locales/zh-Hant.json +++ b/src/web/locales/zh-Hant.json @@ -1301,5 +1301,8 @@ "{min}–{max} alternatives": "{min}–{max} 個備選", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "不含首選方案。這是目標範圍,AI 會依內容調整實際數量;更多備選需要更長的準備時間。", "Other options · {n}": "其他方案 · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "隨修改一起準備;數量不含首選方案,僅供參考。只影響之後的請求。" + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "隨修改一起準備;數量不含首選方案,僅供參考。只影響之後的請求。", + "Linked changes": "對應修改", + "Change {n}": "修改 {n}", + "Show change {n}": "定位修改 {n}" } diff --git a/src/web/note-results.tsx b/src/web/note-results.tsx index 96d6946e..324b6ff3 100644 --- a/src/web/note-results.tsx +++ b/src/web/note-results.tsx @@ -194,7 +194,7 @@ export function NoteReviewControls({ doc }: { doc: Document | null }) { const frame = doc.defaultView?.frameElement?.getBoundingClientRect(); const viewport = element.parentElement!.getBoundingClientRect(); const bottom = viewport.bottom - element.offsetHeight - 12 - (frame?.top || 0); - const marks = [...paper.querySelectorAll('.papilot-linked-change .pdf-focus-marks rect')].map(mark => mark.getBoundingClientRect()); + const marks = [...paper.querySelectorAll('.papilot-linked-target .pdf-focus-marks rect')].map(mark => mark.getBoundingClientRect()); if (!marks.length) return; const top = Math.min(...marks.map(mark => mark.top)), end = Math.max(...marks.map(mark => mark.bottom)); const visibleTop = paper.getBoundingClientRect().top + 48; @@ -233,11 +233,22 @@ export function NoteReviewControls({ doc }: { doc: Document | null }) { event.preventDefault(); move(event.key === 'ArrowLeft' ? -1 : 1); }}> - {index + 1}{siblings.length} + {t('Note {n}', { n: index + 1 })}{siblings.length} ; const emptyHeader =
{navigation}
; + // Use the review's change numbers, not the separate annotation index. + // The options endpoint compares this note's round; older tracked fragments + // from other rounds are not part of the comparison in this drawer. + const changes = displayed.changes.filter(change => change.round === displayed.round); return
+ {changes.length > 0 &&
+ {t('Linked changes')} +
{changes.map(change => )}
+
} {links.error ? <>{emptyHeader} : displayed.changes.length > 0 ? : <>{emptyHeader}

{displayed.state === 'waiting' ? t('Awaiting changes') : t('No linked change')}

}
; } diff --git a/src/web/review-note-connections.tsx b/src/web/review-note-connections.tsx index 381ba524..65f65b6e 100644 --- a/src/web/review-note-connections.tsx +++ b/src/web/review-note-connections.tsx @@ -40,16 +40,28 @@ export function ReviewNoteConnections({ doc, round }: { doc: Document | null; ro scan(); const observer = new MutationObserver(scan); observer.observe(doc.body, { childList: true, subtree: true }); doc.defaultView?.addEventListener('papilot:round', scan); - return () => { observer.disconnect(); doc.defaultView?.removeEventListener('papilot:round', scan); slots.forEach((pair, element) => { pair.slot.remove(); pair.optionsSlot.remove(); element.classList.remove('papilot-linked-change'); }); }; + return () => { observer.disconnect(); doc.defaultView?.removeEventListener('papilot:round', scan); slots.forEach((pair, element) => { pair.slot.remove(); pair.optionsSlot.remove(); element.classList.remove('papilot-linked-change', 'papilot-linked-target'); }); }; }, [doc, links?.notes, round]); useEffect(() => { const focus = links?.focus; const selected = links?.notes.find(note => note.key === focus?.key); - for (const place of places) place.element.classList.toggle('papilot-linked-change', Boolean(links?.scope === 'note' && selected && place.notes.some(note => note.key === selected.key))); + for (const place of places) { + const linked = Boolean(links?.scope === 'note' && selected && place.notes.some(note => note.key === selected.key)); + place.element.classList.toggle('papilot-linked-change', linked); + place.element.classList.toggle('papilot-linked-target', linked && place.round === focus?.change.round && place.stop === focus.change.stop); + } if (!doc || !focus || (jumped.current?.doc === doc && jumped.current.sequence === focus.sequence)) return; - const place = places.find(place => place.round === focus.change.round && place.notes.some(note => note.key === focus.key)); + const place = places.find(place => place.round === focus.change.round && place.stop === focus.change.stop && place.notes.some(note => note.key === focus.key)); if (!place) return; - place.element.click(); place.element.scrollIntoView({ block: 'center' }); place.element.focus({ preventScroll: true }); + place.element.click(); + // Scroll only the PDF pane: scrollIntoView can also move the host page + // horizontally or leave the target behind the bottom drawer. + const paper = doc.getElementById('paper'); + if (paper) { + const padding = parseFloat(doc.defaultView?.getComputedStyle(paper).scrollPaddingTop || '') || 0; + paper.scrollBy({ top: place.element.getBoundingClientRect().top - paper.getBoundingClientRect().top - padding - 12, behavior: 'instant' }); + } + place.element.focus({ preventScroll: true }); jumped.current = { doc, sequence: focus.sequence }; }, [doc, places, links?.focus, links?.scope]); useEffect(() => { @@ -66,6 +78,11 @@ export function ReviewNoteConnections({ doc, round }: { doc: Document | null; ro return () => { target.removeEventListener('papilot:round', send); target.removeEventListener('papilot:note-focus-ready', send); }; }, [doc, round, links?.focus, links?.notes, links?.preview, links?.scope]); if (!doc || !links) return null; + const focusAt = (place: Place, note: NoteResult) => { + const change = note.changes.find(change => change.round === place.round && change.stop === place.stop); + if (change) latest.current?.showChange(note.key, change); + else latest.current?.focusNote(note.key); + }; return <>{createPortal(, doc.head)}{places.map(place => createPortal(<>{place.notes.map(note => )}, place.slot, `${place.round}:${place.stop}`))}{places.map(place => { + `}, doc.head)}{places.map(place => createPortal(<>{place.notes.map(note => )}, place.slot, `${place.round}:${place.stop}`))}{places.map(place => { const available = place.notes.filter(note => links.alternativeCounts[note.key] > 0); const note = available.find(note => note.key === links.focus?.key) || available[0]; - return createPortal(note ? : null, place.optionsSlot, `options:${place.round}:${place.stop}`); + return createPortal(note ? : null, place.optionsSlot, `options:${place.round}:${place.stop}`); })}; } diff --git a/src/web/style.css b/src/web/style.css index 62155a6b..3807c108 100644 --- a/src/web/style.css +++ b/src/web/style.css @@ -1065,6 +1065,12 @@ body.resizing iframe{pointer-events:none} .review-note-controls { position: absolute; z-index: 5; inset: auto 0 0; padding: 0 0 env(safe-area-inset-bottom, 0px); border: 0; border-top: 1px solid var(--line-strong); border-radius: 0; background: var(--bg); box-shadow: 0 -4px 16px rgb(0 0 0 / .06); max-height: min(44%, 300px); display: flex; flex-direction: column; overflow: hidden; transform: translateY(100%); transition: transform .26s var(--ease); } .review-note-controls[data-open="true"] { transform: translateY(0); } .review-note-controls[data-open="false"] { pointer-events: none; } +.note-review-targets { display: flex; align-items: center; gap: 8px; flex-shrink: 0; min-width: 0; padding: 6px 12px; border-bottom: 1px solid var(--line); font-size: 12px; } +.note-review-targets > span { flex-shrink: 0; color: var(--text-2); } +.note-review-targets > div { display: flex; gap: 4px; min-width: 0; overflow-x: auto; } +.note-review-targets button { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; border: 1px solid var(--line); border-radius: 5px; padding: 3px 7px; background: var(--bg); color: var(--text); font: inherit; white-space: nowrap; cursor: pointer; } +.note-review-targets button:hover, .note-review-targets button[aria-pressed="true"] { color: var(--primary); border-color: var(--primary); background: var(--accent-soft); } +.note-review-targets button:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; } @starting-style { .review-note-controls[data-open="true"] { transform: translateY(100%); } } @media (prefers-reduced-motion: reduce) { .review-note-controls { transition: none; } } .review-note-pager { display: flex; align-items: center; flex-shrink: 0; gap: 2px; margin: 0 0 4px auto; color: var(--text-2); } From a3320543ab222d204d1be428f926b1e6e25a5f3a Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Mon, 9 Nov 2026 14:28:49 +0800 Subject: [PATCH 2/3] style: compact note links and agent connection status --- scripts/check-note-results.mjs | 8 ++++++++ scripts/check-task-model.mjs | 22 ++++++++++++++++++++++ src/web/agent-picker.tsx | 7 ++++--- src/web/review-note-connections.tsx | 13 ++++++++----- src/web/style.css | 14 +++++++++----- 5 files changed, 51 insertions(+), 13 deletions(-) diff --git a/scripts/check-note-results.mjs b/scripts/check-note-results.mjs index b28a1f04..1312ca8f 100644 --- a/scripts/check-note-results.mjs +++ b/scripts/check-note-results.mjs @@ -30,6 +30,9 @@ const items = [ { id: 'request-2', taskId: task.id, type: 'user', text: '', createdAt: 3, meta: { notes: [{ ...note('Keep the technical term.', 10, 'A shorter technical sentence.'), where: 'Round 1 · Page 1 · main.tex line 10', anchor: { round: 1, sources: [{ file: 'main.tex', lines: [10, 12] }] } }] } }, ]; const runs = [{ id: 'request-1', taskId: task.id, reviewRound: 1, status: 'completed' }, { id: 'request-2', taskId: task.id, reviewRound: 2, status: 'completed' }]; +// Several annotations can legitimately share a PDF group. Its links stay +// compact and individually usable instead of wrapping into tall cards. +items[2].meta.notes.push(...['Keep the definition precise.', 'Avoid unnecessary repetition.'].map(comment => ({ ...items[2].meta.notes[0], comment }))); const unit = (id, line, old, revised) => ({ id, file: 'main.tex', kind: 'text', status: 'modified', old_start: line, old_end: line + 2, new_start: line, new_end: line + 2, old_text: old, new_text: revised }); const units = { 1: [unit('c1', 10, 'The original technical sentence.', 'A shorter technical sentence.'), unit('c2', 20, 'A second original sentence.', 'Our contribution is now explicit.'), unit('c3', 12, 'The original technical sentence.', 'A clearer technical sentence.')], 2: [unit('c1', 11, 'A shorter technical sentence.', 'A shorter sentence preserving the technical term.')] }; const rounds = Object.fromEntries([1, 2].map(round => [round, { round, units: units[round], sections: [], loose: units[round].map(unit => ({ kind: 'change', units: [unit.id], before: [{ text: [{ text: unit.old_text }] }] })) }])); @@ -149,6 +152,11 @@ try { await frame().locator('html[data-papilot-round="2"]').waitFor(); await frame().locator('.papilot-linked-change[data-stop="0"]').waitFor(); await expect(controls().locator('.note-review-targets button')).toHaveText(['Change 1']); + const noteLinks = frame().locator('.change[data-stop="0"] .papilot-note-links'); + await expect(noteLinks.locator('button')).toHaveCount(3); + await expect(noteLinks.locator('svg')).toHaveCount(1); + const linkBounds = await noteLinks.locator('button').evaluateAll(buttons => buttons.map(button => { const box = button.getBoundingClientRect(); return { top: box.top, bottom: box.bottom }; })); + assert.ok(linkBounds.every(box => box.top === linkBounds[0].top && box.bottom === linkBounds[0].bottom), 'Shared note numbers stay on one compact row'); assert.doesNotMatch(await page.locator('.review-frame').getAttribute('src'), /[?&](history|round)=/); assert.deepEqual(await page.locator('[data-note-result]').allTextContents(), original, 'review operations never add content to original messages'); await controls().getByRole('region', { name: 'Complete PDF comparison', exact: true }).waitFor(); diff --git a/scripts/check-task-model.mjs b/scripts/check-task-model.mjs index cdc18f99..f2e0e1ab 100644 --- a/scripts/check-task-model.mjs +++ b/scripts/check-task-model.mjs @@ -121,6 +121,28 @@ try { const box = await taskModelMenu.boundingBox(); assert.ok(box.x >= 0 && box.x + box.width <= 390); await page.screenshot({ path: 'test-results/task-model-mobile.png' }); + await taskModelMenu.getByRole('combobox').press('Escape'); + Object.assign(runtime, { connected: false, authenticated: false, error: null }); + await page.evaluate(() => window.dispatchEvent(new Event('online'))); + const connection = page.locator('.composer-wrap > .agent-connection'); + await expect(connection).toHaveAttribute('data-state', 'connecting'); + await expect(connection.getByRole('status')).toContainText('Connecting to Codex'); + assert.equal(await connection.locator('.agent-connection-message > svg').count(), 0, 'Normal startup has no warning icon'); + assert.equal(await connection.evaluate(element => getComputedStyle(element).backgroundColor), 'rgba(0, 0, 0, 0)', 'Normal startup has no warning card background'); + await page.screenshot({ path: 'test-results/agent-connecting-mobile.png' }); + Object.assign(runtime, { error: 'Connection refused: The agent did not respond.' }); + await page.evaluate(() => window.dispatchEvent(new Event('online'))); + await expect(connection).toHaveAttribute('data-state', 'error'); + await expect(connection.getByRole('button', { name: 'Reconnect', exact: true })).toBeEnabled(); + await connection.getByText('Connection details', { exact: true }).click(); + await expect(connection.getByText(runtime.error, { exact: true })).toBeVisible(); + await page.screenshot({ path: 'test-results/agent-connection-error-mobile.png' }); + Object.assign(runtime, { connected: true, authenticated: false, error: null }); + await page.evaluate(() => window.dispatchEvent(new Event('online'))); + await expect(connection.getByRole('status')).toHaveText('Sign-in required'); + Object.assign(runtime, { connected: true, authenticated: true, error: null }); + await page.evaluate(() => window.dispatchEvent(new Event('online'))); + await expect(connection).toHaveCount(0); assert.deepEqual(errors, []); console.log('PASS: Task model and effort stay editable within the same agent, persist, serialize changes, recover from save failures and fit mobile'); diff --git a/src/web/agent-picker.tsx b/src/web/agent-picker.tsx index 5136f442..d13192cd 100644 --- a/src/web/agent-picker.tsx +++ b/src/web/agent-picker.tsx @@ -15,11 +15,12 @@ export function ModelLabel({ model, effort, fast }: { model: string; effort?: st export function AgentConnectionNotice({ name, runtime, reconnecting, onReconnect }: { name: string; runtime?: RuntimeStatus; reconnecting: boolean; onReconnect: () => void }) { if (!runtime || (runtime.connected && runtime.authenticated)) return null; const error = runtime.error || '', first = error.split(/:\s|\.\s/)[0]; + const connecting = runtime.installed !== false && !error && !runtime.connected; // An agent that was not found is not connecting: say so until it is installed and Reconnect finds it. const message = runtime.installed === false ? t('{agent} was not found on this computer. Install or set it up, then select Reconnect.', { agent: name }) - : error ? (first.length <= 120 ? first : t('{agent} is unavailable', { agent: name })) : t('Connecting to {agent}…', { agent: name }); - return
-
{message}
+ : error ? (first.length <= 120 ? first : t('{agent} is unavailable', { agent: name })) : runtime.connected ? t('Sign-in required') : t('Connecting to {agent}…', { agent: name }); + return
+
{connecting ?
{error && error !== message &&
{t('Connection details')}

{error}

}
; diff --git a/src/web/review-note-connections.tsx b/src/web/review-note-connections.tsx index 65f65b6e..50d8b3f6 100644 --- a/src/web/review-note-connections.tsx +++ b/src/web/review-note-connections.tsx @@ -84,9 +84,12 @@ export function ReviewNoteConnections({ doc, round }: { doc: Document | null; ro else latest.current?.focusNote(note.key); }; return <>{createPortal(, doc.head)}{places.map(place => createPortal(<>{place.notes.map(note => )}, place.slot, `${place.round}:${place.stop}`))}{places.map(place => { + @media(pointer:coarse){.papilot-note-links button{min-height:32px;min-width:28px}} + `}, doc.head)}{places.map(place => createPortal(<>{place.notes.map(note => )}, place.slot, `${place.round}:${place.stop}`))}{places.map(place => { const available = place.notes.filter(note => links.alternativeCounts[note.key] > 0); const note = available.find(note => note.key === links.focus?.key) || available[0]; return createPortal(note ? : null, place.optionsSlot, `options:${place.round}:${place.stop}`); diff --git a/src/web/style.css b/src/web/style.css index 3807c108..ec3c4d00 100644 --- a/src/web/style.css +++ b/src/web/style.css @@ -520,14 +520,18 @@ body.resizing iframe{pointer-events:none} .task-permissions-hint{color:var(--text-2)} .task-permissions fieldset:disabled .task-permission-option{opacity:.6;cursor:default} .agent-status-mark{display:flex;flex:none;color:var(--amber-text)} -.agent-connection{margin-top:10px;padding:10px 12px;border:1px solid var(--amber-line);border-radius:10px;background:var(--amber-soft);font-size:12px;line-height:1.5;color:var(--text-2)} -.agent-connection-message{display:flex;align-items:flex-start;gap:7px;color:var(--amber-text)} +.agent-connection{display:flex;align-items:center;flex-wrap:wrap;gap:4px 10px;margin-top:8px;padding:5px 2px;font-size:12px;line-height:1.5;color:var(--text-2)} +.agent-connection[data-state="error"]{padding:8px 10px;border-left:2px solid var(--amber-line);border-radius:2px;background:var(--amber-soft)} +.agent-connection-message{display:flex;align-items:flex-start;flex:1;min-width:0;gap:7px} +.agent-connection[data-state="error"] .agent-connection-message{color:var(--amber-text)} .agent-connection-message>svg{flex:none;margin-top:2px} -.agent-connection-message>span{min-width:0;overflow-wrap:anywhere} -.agent-connection details{margin:6px 0 0 22px} +.agent-connection-message>.spinner{flex:none;width:12px;height:12px;margin-top:3px} +.agent-connection-message>span:last-child{min-width:0;overflow-wrap:anywhere} +.agent-connection details{order:3;flex-basis:100%;margin:2px 0 0 21px} .agent-connection summary{cursor:pointer;font-size:11px} .agent-connection p{margin:6px 0;overflow-wrap:anywhere;font-size:12px} -.agent-connection>button{display:flex;align-items:center;gap:5px;margin:6px 0 -2px 16px;padding:3px 6px;border-radius:5px;color:var(--accent);font-size:12px;font-weight:500} +.agent-connection>button{display:flex;align-items:center;flex:none;gap:4px;padding:3px 5px;border-radius:4px;color:var(--accent);font-size:11px} +.agent-connection[data-state="connecting"]>button{color:var(--text-3)} .agent-connection>button:hover:not(:disabled){background:var(--bg-hover)} .agent-connection>button:disabled{opacity:.6;cursor:wait} .composer-wrap>.agent-connection{max-width:760px;margin:0 auto 8px} From bdf295188af10f35c305be1e3e26a502c5b79130 Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Mon, 9 Nov 2026 14:45:27 +0800 Subject: [PATCH 3/3] fix: connect note previews through selection highlights --- scripts/check-note-results.mjs | 22 ++++++++++++++-------- src/web/locales/de.json | 5 +---- src/web/locales/es.json | 5 +---- src/web/locales/fr.json | 5 +---- src/web/locales/it.json | 5 +---- src/web/locales/ja.json | 5 +---- src/web/locales/ko.json | 5 +---- src/web/locales/pt.json | 5 +---- src/web/locales/sv.json | 5 +---- src/web/locales/zh-CN.json | 5 +---- src/web/locales/zh-Hant.json | 5 +---- src/web/note-results.tsx | 11 ----------- src/web/review-note-connections.tsx | 6 ++++-- src/web/style.css | 10 +++------- 14 files changed, 31 insertions(+), 68 deletions(-) diff --git a/scripts/check-note-results.mjs b/scripts/check-note-results.mjs index 1312ca8f..81a055cd 100644 --- a/scripts/check-note-results.mjs +++ b/scripts/check-note-results.mjs @@ -121,7 +121,8 @@ try { await frame().locator('.papilot-linked-change[data-stop="1"]').waitFor(); assert.equal(await frame().locator('.papilot-note-options button').count(), 0, 'Historical changes do not advertise unavailable alternatives'); assert.equal(await controls().locator('.review-note-position').textContent(), 'Note 2 / 3'); - await expect(controls().getByRole('button', { name: 'Show change 2', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await expect(frame().locator('.papilot-linked-target')).toHaveAttribute('data-stop', '1'); + await expect(controls().locator('.note-review-targets')).toHaveCount(0); assert.equal(await controls().getByRole('button', { name: 'Previous note', exact: true }).isEnabled(), true); assert.equal(await controls().getByRole('button', { name: 'Next note', exact: true }).isEnabled(), true); assert.match(await page.locator('.review-frame').getAttribute('src'), /round=1&history=1/); @@ -130,19 +131,20 @@ try { await frame().locator('body').evaluate(element => element.ownerDocument.defaultView.dispatchEvent(new Event('latex-review:decided'))); await sent(0).click(); assert.equal(await controls().locator('.review-note-position').textContent(), 'Note 1 / 3'); - await expect(controls().locator('.note-review-targets button')).toHaveText(['Change 1', 'Change 3']); - await controls().getByRole('button', { name: 'Show change 3', exact: true }).click(); + await expect(frame().locator('.papilot-linked-change')).toHaveCount(2); + await frame().locator('.change[data-stop="2"] .papilot-note-links button').click(); await expect(frame().locator('.papilot-linked-target')).toHaveAttribute('data-stop', '2'); - await expect(controls().getByRole('button', { name: 'Show change 3', exact: true })).toHaveAttribute('aria-pressed', 'true'); + const selectedColor = await frame().locator('.papilot-linked-target').evaluate(element => getComputedStyle(element).outlineColor); + assert.equal(await controls().evaluate(element => getComputedStyle(element).borderTopColor), selectedColor, 'The drawer and its exact source change share a selection accent'); + assert.notEqual(await frame().locator('.change[data-stop="0"] .change-head').evaluate(element => getComputedStyle(element).backgroundColor), await frame().locator('.papilot-linked-target .change-head').evaluate(element => getComputedStyle(element).backgroundColor), 'Other related fragments do not look like the selected target'); assert.ok(await frame().locator('.papilot-linked-target').evaluate(element => { const bounds = element.getBoundingClientRect(); return bounds.top < window.innerHeight && bounds.bottom > 0; - }), 'A change link reveals its own change instead of the note’s first change'); - await controls().getByRole('button', { name: 'Show change 1', exact: true }).click(); + }), 'A note badge reveals its own change instead of the note’s first change'); + await frame().locator('.change[data-stop="0"] .papilot-note-links button').click(); await expect(frame().locator('.papilot-linked-target')).toHaveAttribute('data-stop', '0'); await frame().locator('.change[data-stop="2"] .papilot-note-links button').click(); await expect(frame().locator('.papilot-linked-target')).toHaveAttribute('data-stop', '2'); - await expect(controls().getByRole('button', { name: 'Show change 3', exact: true })).toHaveAttribute('aria-pressed', 'true'); const linkedHeader = await frame().locator('.papilot-linked-target .change-head').boundingBox(); const linkedDrawer = await controls().boundingBox(); assert.ok(linkedHeader.y + linkedHeader.height <= linkedDrawer.y, 'The linked header stays above the drawer'); @@ -151,7 +153,7 @@ try { await currentNote.click(); await frame().locator('html[data-papilot-round="2"]').waitFor(); await frame().locator('.papilot-linked-change[data-stop="0"]').waitFor(); - await expect(controls().locator('.note-review-targets button')).toHaveText(['Change 1']); + await expect(frame().locator('.papilot-linked-target')).toHaveAttribute('data-stop', '0'); const noteLinks = frame().locator('.change[data-stop="0"] .papilot-note-links'); await expect(noteLinks.locator('button')).toHaveCount(3); await expect(noteLinks.locator('svg')).toHaveCount(1); @@ -207,8 +209,12 @@ try { await controls().waitFor({ state: 'hidden' }); await frame().getByRole('button', { name: 'Show note 1: Keep the technical term.', exact: true }).click(); await controls().waitFor({ state: 'visible' }); + await controls().getByRole('region', { name: 'Complete PDF comparison', exact: true }).waitFor(); fs.mkdirSync('test-results', { recursive: true }); await page.screenshot({ path: 'test-results/note-results-desktop.png', fullPage: true }); + await page.emulateMedia({ colorScheme: 'light' }); + await page.screenshot({ path: 'test-results/note-results-desktop-light.png', fullPage: true }); + await page.emulateMedia({ colorScheme: 'dark' }); await page.reload(); await page.setViewportSize({ width: 390, height: 844 }); diff --git a/src/web/locales/de.json b/src/web/locales/de.json index 5501ac76..886fcd40 100644 --- a/src/web/locales/de.json +++ b/src/web/locales/de.json @@ -1301,8 +1301,5 @@ "{min}–{max} alternatives": "{min}–{max} Alternativen", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "Ein Zielbereich ohne den Hauptvorschlag. Die KI kann weniger oder mehr sinnvolle Alternativen liefern. Mehr Alternativen benötigen mehr Vorbereitungszeit.", "Other options · {n}": "Weitere Optionen · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Zusammen mit den Änderungen vorbereitet. Die Anzahl ist ein Richtwert ohne den Hauptvorschlag. Gilt für künftige Anfragen.", - "Linked changes": "Zugehörige Änderungen", - "Change {n}": "Änderung {n}", - "Show change {n}": "Änderung {n} anzeigen" + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Zusammen mit den Änderungen vorbereitet. Die Anzahl ist ein Richtwert ohne den Hauptvorschlag. Gilt für künftige Anfragen." } diff --git a/src/web/locales/es.json b/src/web/locales/es.json index 31072728..1f84e01d 100644 --- a/src/web/locales/es.json +++ b/src/web/locales/es.json @@ -1301,8 +1301,5 @@ "{min}–{max} alternatives": "{min}–{max} alternativas", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "Un intervalo orientativo, sin contar la propuesta principal. La IA puede ofrecer menos o más alternativas útiles. Más alternativas requieren más tiempo de preparación.", "Other options · {n}": "Otras opciones · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Se preparan con los cambios. Cantidad aproximada, sin contar la propuesta principal. Se aplica a futuras solicitudes.", - "Linked changes": "Cambios vinculados", - "Change {n}": "Cambio {n}", - "Show change {n}": "Mostrar el cambio {n}" + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Se preparan con los cambios. Cantidad aproximada, sin contar la propuesta principal. Se aplica a futuras solicitudes." } diff --git a/src/web/locales/fr.json b/src/web/locales/fr.json index ccce7929..6fb3824d 100644 --- a/src/web/locales/fr.json +++ b/src/web/locales/fr.json @@ -1301,8 +1301,5 @@ "{min}–{max} alternatives": "{min}–{max} variantes", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "Une fourchette indicative, hors proposition principale. L’IA peut proposer moins ou plus de variantes utiles. Plus de variantes demandent plus de temps de préparation.", "Other options · {n}": "Autres options · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Préparées avec les modifications. Nombre indicatif, hors proposition principale. S’applique aux prochaines demandes.", - "Linked changes": "Modifications associées", - "Change {n}": "Modification {n}", - "Show change {n}": "Afficher la modification {n}" + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Préparées avec les modifications. Nombre indicatif, hors proposition principale. S’applique aux prochaines demandes." } diff --git a/src/web/locales/it.json b/src/web/locales/it.json index b22d9ded..f833077a 100644 --- a/src/web/locales/it.json +++ b/src/web/locales/it.json @@ -1301,8 +1301,5 @@ "{min}–{max} alternatives": "{min}–{max} alternative", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "Un intervallo indicativo, esclusa la proposta principale. L’IA può fornire meno o più alternative utili. Più alternative richiedono più tempo di preparazione.", "Other options · {n}": "Altre opzioni · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Preparate insieme alle modifiche. Numero indicativo, esclusa la proposta principale. Si applica alle richieste future.", - "Linked changes": "Modifiche collegate", - "Change {n}": "Modifica {n}", - "Show change {n}": "Mostra la modifica {n}" + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Preparate insieme alle modifiche. Numero indicativo, esclusa la proposta principale. Si applica alle richieste future." } diff --git a/src/web/locales/ja.json b/src/web/locales/ja.json index ebee9f81..d5e4949f 100644 --- a/src/web/locales/ja.json +++ b/src/web/locales/ja.json @@ -1301,8 +1301,5 @@ "{min}–{max} alternatives": "{min}〜{max} 個の別案", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "主案を除いた目安です。AI が返す有用な案の数は前後することがあります。案が多いほど準備に時間がかかります。", "Other options · {n}": "別の案 · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "編集と同時に用意します。主案を除く数の目安です。今後のリクエストに適用されます。", - "Linked changes": "対応する変更", - "Change {n}": "変更 {n}", - "Show change {n}": "変更 {n} を表示" + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "編集と同時に用意します。主案を除く数の目安です。今後のリクエストに適用されます。" } diff --git a/src/web/locales/ko.json b/src/web/locales/ko.json index 7e51db80..b977a4aa 100644 --- a/src/web/locales/ko.json +++ b/src/web/locales/ko.json @@ -1301,8 +1301,5 @@ "{min}–{max} alternatives": "대안 {min}–{max}개", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "기본 제안을 제외한 목표 범위입니다. AI가 유용한 대안을 더 적게 또는 많이 제시할 수 있습니다. 대안이 많을수록 준비 시간이 길어집니다.", "Other options · {n}": "다른 대안 · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "편집과 함께 준비합니다. 개수는 기본 제안을 제외한 대략적인 수이며, 이후 요청부터 적용됩니다.", - "Linked changes": "연결된 변경 사항", - "Change {n}": "변경 {n}", - "Show change {n}": "변경 {n} 보기" + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "편집과 함께 준비합니다. 개수는 기본 제안을 제외한 대략적인 수이며, 이후 요청부터 적용됩니다." } diff --git a/src/web/locales/pt.json b/src/web/locales/pt.json index 087b2126..7d7fc24a 100644 --- a/src/web/locales/pt.json +++ b/src/web/locales/pt.json @@ -1301,8 +1301,5 @@ "{min}–{max} alternatives": "{min}–{max} alternativas", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "Um intervalo indicativo, sem contar a proposta principal. A IA pode apresentar menos ou mais alternativas úteis. Mais alternativas exigem mais tempo de preparação.", "Other options · {n}": "Outras opções · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Preparadas com as alterações. Quantidade aproximada, sem contar a proposta principal. Aplica-se a pedidos futuros.", - "Linked changes": "Alterações vinculadas", - "Change {n}": "Alteração {n}", - "Show change {n}": "Mostrar a alteração {n}" + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Preparadas com as alterações. Quantidade aproximada, sem contar a proposta principal. Aplica-se a pedidos futuros." } diff --git a/src/web/locales/sv.json b/src/web/locales/sv.json index 38c131e5..e5c14a49 100644 --- a/src/web/locales/sv.json +++ b/src/web/locales/sv.json @@ -1301,8 +1301,5 @@ "{min}–{max} alternatives": "{min}–{max} alternativ", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "Ett riktmärke som inte räknar med huvudförslaget. AI:n kan ge färre eller fler användbara alternativ. Fler alternativ tar längre tid att förbereda.", "Other options · {n}": "Andra alternativ · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Förbereds med ändringarna. Antalet är ungefärligt och räknar inte med huvudförslaget. Gäller framtida förfrågningar.", - "Linked changes": "Länkade ändringar", - "Change {n}": "Ändring {n}", - "Show change {n}": "Visa ändring {n}" + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "Förbereds med ändringarna. Antalet är ungefärligt och räknar inte med huvudförslaget. Gäller framtida förfrågningar." } diff --git a/src/web/locales/zh-CN.json b/src/web/locales/zh-CN.json index 9c860be2..4ecceef1 100644 --- a/src/web/locales/zh-CN.json +++ b/src/web/locales/zh-CN.json @@ -125,8 +125,5 @@ "{min}–{max} alternatives": "{min}–{max} 个备选", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "不含首选方案。这是目标范围,AI 会按内容调整实际数量;更多备选需要更长的准备时间。", "Other options · {n}": "其他方案 · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "随修改一起准备;数量不含首选方案,仅作参考。只影响之后的请求。", - "Linked changes": "对应修改", - "Change {n}": "修改 {n}", - "Show change {n}": "定位修改 {n}" + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "随修改一起准备;数量不含首选方案,仅作参考。只影响之后的请求。" } diff --git a/src/web/locales/zh-Hant.json b/src/web/locales/zh-Hant.json index 2e8fc0a2..b3b0c421 100644 --- a/src/web/locales/zh-Hant.json +++ b/src/web/locales/zh-Hant.json @@ -1301,8 +1301,5 @@ "{min}–{max} alternatives": "{min}–{max} 個備選", "A target range, excluding the main proposal. The AI may return fewer or more useful choices. More choices take longer to prepare.": "不含首選方案。這是目標範圍,AI 會依內容調整實際數量;更多備選需要更長的準備時間。", "Other options · {n}": "其他方案 · {n}", - "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "隨修改一起準備;數量不含首選方案,僅供參考。只影響之後的請求。", - "Linked changes": "對應修改", - "Change {n}": "修改 {n}", - "Show change {n}": "定位修改 {n}" + "Prepared with edits. Counts exclude the main proposal and are approximate. Applies to future requests.": "隨修改一起準備;數量不含首選方案,僅供參考。只影響之後的請求。" } diff --git a/src/web/note-results.tsx b/src/web/note-results.tsx index 324b6ff3..f9b5d3a3 100644 --- a/src/web/note-results.tsx +++ b/src/web/note-results.tsx @@ -237,18 +237,7 @@ export function NoteReviewControls({ doc }: { doc: Document | null }) { ; const emptyHeader =
{navigation}
; - // Use the review's change numbers, not the separate annotation index. - // The options endpoint compares this note's round; older tracked fragments - // from other rounds are not part of the comparison in this drawer. - const changes = displayed.changes.filter(change => change.round === displayed.round); return
- {changes.length > 0 &&
- {t('Linked changes')} -
{changes.map(change => )}
-
} {links.error ? <>{emptyHeader} : displayed.changes.length > 0 ? : <>{emptyHeader}

{displayed.state === 'waiting' ? t('Awaiting changes') : t('No linked change')}

}
; } diff --git a/src/web/review-note-connections.tsx b/src/web/review-note-connections.tsx index 50d8b3f6..b58ee1a5 100644 --- a/src/web/review-note-connections.tsx +++ b/src/web/review-note-connections.tsx @@ -95,8 +95,10 @@ export function ReviewNoteConnections({ doc, round }: { doc: Document | null; ro .papilot-note-options button{display:inline-flex;align-items:center;gap:3px;padding:4px;border:0;border-radius:5px;background:transparent;color:var(--accent);font:11px/1.5 var(--sans);white-space:nowrap;cursor:pointer} .papilot-note-options button:hover,.papilot-note-options button[aria-expanded=true]{background:var(--accent-soft)} .papilot-note-options button:focus-visible{outline:2px solid var(--accent);outline-offset:1px} - .papilot-linked-change{outline:2px solid var(--primary);outline-offset:-2px} - .papilot-linked-target>.change-head{background:var(--accent-soft);box-shadow:inset 3px 0 var(--primary)} + .papilot-linked-change>.change-head{box-shadow:inset 3px 0 color-mix(in srgb,var(--primary) 35%,transparent)} + .papilot-linked-target{outline:2px solid var(--primary);outline-offset:-2px} + .papilot-linked-target>.change-head{background:color-mix(in srgb,var(--primary) 12%,var(--bg));box-shadow:inset 3px 0 var(--primary)} + .papilot-linked-target>.change-head .change-number{color:var(--primary);font-weight:600} @media(pointer:coarse){.papilot-note-links button{min-height:32px;min-width:28px}} `}, doc.head)}{places.map(place => createPortal(<>{place.notes.map(note => )}, place.slot, `${place.round}:${place.stop}`))}{places.map(place => { const available = place.notes.filter(note => links.alternativeCounts[note.key] > 0); diff --git a/src/web/style.css b/src/web/style.css index ec3c4d00..a13db729 100644 --- a/src/web/style.css +++ b/src/web/style.css @@ -1067,14 +1067,10 @@ body.resizing iframe{pointer-events:none} .sent-notes li[data-note-result] { cursor: pointer; } .sent-notes li[data-note-result]:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; } .review-note-controls { position: absolute; z-index: 5; inset: auto 0 0; padding: 0 0 env(safe-area-inset-bottom, 0px); border: 0; border-top: 1px solid var(--line-strong); border-radius: 0; background: var(--bg); box-shadow: 0 -4px 16px rgb(0 0 0 / .06); max-height: min(44%, 300px); display: flex; flex-direction: column; overflow: hidden; transform: translateY(100%); transition: transform .26s var(--ease); } -.review-note-controls[data-open="true"] { transform: translateY(0); } +.review-note-controls[data-open="true"] { transform: translateY(0); border-top: 2px solid var(--primary); } .review-note-controls[data-open="false"] { pointer-events: none; } -.note-review-targets { display: flex; align-items: center; gap: 8px; flex-shrink: 0; min-width: 0; padding: 6px 12px; border-bottom: 1px solid var(--line); font-size: 12px; } -.note-review-targets > span { flex-shrink: 0; color: var(--text-2); } -.note-review-targets > div { display: flex; gap: 4px; min-width: 0; overflow-x: auto; } -.note-review-targets button { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; border: 1px solid var(--line); border-radius: 5px; padding: 3px 7px; background: var(--bg); color: var(--text); font: inherit; white-space: nowrap; cursor: pointer; } -.note-review-targets button:hover, .note-review-targets button[aria-pressed="true"] { color: var(--primary); border-color: var(--primary); background: var(--accent-soft); } -.note-review-targets button:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; } +.review-note-controls[data-open="true"] .note-option-navigation { background: color-mix(in srgb, var(--primary) 8%, var(--bg)); } +.review-note-controls[data-open="true"] .review-note-position { color: var(--primary); } @starting-style { .review-note-controls[data-open="true"] { transform: translateY(100%); } } @media (prefers-reduced-motion: reduce) { .review-note-controls { transition: none; } } .review-note-pager { display: flex; align-items: center; flex-shrink: 0; gap: 2px; margin: 0 0 4px auto; color: var(--text-2); }