diff --git a/scripts/check-note-results.mjs b/scripts/check-note-results.mjs index 3ff6a87..81a055c 100644 --- a/scripts/check-note-results.mjs +++ b/scripts/check-note-results.mjs @@ -30,10 +30,13 @@ 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.')], 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 +92,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 +120,9 @@ 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(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/); @@ -125,12 +130,35 @@ 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(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'); + 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 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'); + 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(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); + 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(); @@ -181,17 +209,21 @@ 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 }); 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/scripts/check-task-model.mjs b/scripts/check-task-model.mjs index cdc18f9..f2e0e1a 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 5136f44..d13192c 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/note-results.tsx b/src/web/note-results.tsx index 96d6946..f9b5d3a 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,7 +233,7 @@ 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}
; diff --git a/src/web/review-note-connections.tsx b/src/web/review-note-connections.tsx index 381ba52..b58ee1a 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,20 +78,31 @@ 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 => { + .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); 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 62155a6..a13db72 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} @@ -1063,8 +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; } +.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); }